fix: object unions drop data and select the wrong variant (#70) - #72
Merged
Conversation
All members of a `type: [...]` union share one `SchemaDetails`, so the
`object` member may carry no object shape at all. It was still routed
through `add_allocated_object_schema`, which projected it as a closed,
empty struct: `{"payload": {"key": "value"}}` deserialized fine and then
serialized back as `{"payload": {}}`, silently dropping every key.
The single-type path already guards this with `should_use_dynamic_json`;
the typed-multi path did not. Split the object-shape half of
`is_dynamic_object_pattern` into `object_shape_is_unconstrained` so the
union member can ask the same question — `schema_type()` reports only
the first non-null member of a `type: [...]` list, so that caller cannot
reuse the type check — and give an unconstrained member a
`BTreeMap<String, serde_json::Value>` carrier. Unlike `serde_json::Value`
the map matches only objects, so the untagged enum keeps routing arrays
and scalars to their own members.
Members the spec does shape (`properties`, `required`,
`additionalProperties`, `minProperties`/`maxProperties`, and the rest)
keep their generated structs.
The regression test compiles the generated code in a scratch crate and
round-trips every member of the issue's union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19
`serde_json::Value` matches every JSON shape, so an untagged union branch
typed that way claims values belonging to a later branch: with
`anyOf: [{type: object}, {type: string}]` a JSON string deserialized into
the object branch and never reached `String(String)`. Verified both ways
against the compiled generated code — `Some(WVariant(String("hello")))`
before, `Some(String("hello"))` after.
A branch that *declares* `type: object` is not "any JSON", so narrow it to
`BTreeMap<String, serde_json::Value>`: equally lossless, and it matches
only objects. Applied at `add_inline_union_branch_schema`, the single
choke point every union-branch path funnels through.
Only `UntypedReason::OpaqueObject` is narrowed. A branch that declares no
type at all (`{}`, `true`, `{nullable: true}`) really does admit any JSON
and keeps `serde_json::Value` — pinned as the negative case in the new
test, because that distinction is the whole reason the reason code exists.
`odata_nullable_reference_union_keeps_its_literal_object_branch` pinned
the old spelling. Its intent — don't collapse the union, keep a dynamic
branch — is unchanged; only the carrier moves. The explicit `null` that
`{type: object, nullable: true}` admits is still carried by the field's
own `Option`, verified before the assertion was touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lightsofapollo
changed the base branch from
fix/unconstrained-object-union-member
to
main
September 8, 2026 13:43
`#[serde(untagged)]` takes the first branch that deserializes, so a
branch accepting more than its schema allows claims values belonging to
a later one. A struct stating `additionalProperties: false` whose fields
are all optional matched *any* object, won the branch ahead of the one
that actually accepts the value, and dropped the extra keys —
`{"user": {"free": "shape"}}` round-tripped as `{"user": {}}` even
though the union's open branch was right there.
Fixing it first required splitting a conflated model.
`ObjectAdditionalProperties::Forbidden` meant both "the document stated
`additionalProperties: false`" and "the keyword was omitted, so the
generator projects a closed struct". Those are different facts, and
across the 57-spec corpus they are 7,157 and 59,287 object schemas
respectively — keying strictness on `Forbidden` would have made 59k
JSON-Schema-*open* objects reject keys the spec permits. Split into
`Denied` (stated) and `Closed` (projected); the 16 read sites that only
care whether a catch-all field exists now say so via `is_open()`.
`#[serde(deny_unknown_fields)]` is then emitted only when all three
hold: the document stated it (`Denied`), the struct is reachable as an
untagged union branch, and there is no flattened variant (serde rejects
`deny_unknown_fields` alongside `flatten` at compile time). A closed
struct outside any union has no branch to lose and stays tolerant, so
generated clients keep working when a server adds a field.
Measured, not assumed: 83 of 19,369 structs across six large corpus
specs gain the attribute (0.4%; anthropic 41/450, stripe 0/4,984), zero
of 19,019 scanned struct blocks pair it with `flatten`, and anthropic's
generated module compiles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #70, plus two further defects in the same family found while verifying it. Three commits, each independently reviewable.
1. An unconstrained
objectmember of atype: [...]union dropped every key (#70)Every member of
type: [string, number, boolean, object, array, "null"]shares oneSchemaDetails, so theobjectmember can carry no object shape at all. It was still projected as a closed, empty struct:{"payload":{"key":"value"}}deserialized without error and serialized back as{"payload":{}}.Root cause. The single-type path already guards this —
analyze_single_typed_schema'sObjectarm checksshould_use_dynamic_jsonfirst. The typed-multi path had no equivalent, and couldn't reuseis_dynamic_object_patternbecause that starts fromschema_type(), which on aTypedMultireports only the first non-null member (string, here), so the guard could never fire. The object-shape half is now split intoobject_shape_is_unconstrained(&SchemaDetails)and both callers use it.An unconstrained member gets
BTreeMap<String, serde_json::Value>— the shape this codebase already uses for an untyped map (UntypedShape::ValueMap). Crucially it matches only objects, so the untagged enum still routes arrays and scalars to their own members;serde_json::Valuewould swallow them. The generated type name is unchanged.2. A
type: objectunion branch claimed values belonging to later branchesThe object branch became
pub type WVariant = serde_json::Value, which matches every JSON shape, soWP::Stringwas unreachable for every input:{"p": "hello"}deserializes toserde_json::Value(before)Some(WVariant(String("hello")))BTreeMap<String, serde_json::Value>(after)Some(String("hello"))Applied at
add_inline_union_branch_schema, the single choke point every union-branch path funnels through. Keyed onUntypedReason::OpaqueObjectalone — a branch declaring no type ({},true,{nullable: true}) genuinely admits any JSON and keepsserde_json::Value. That distinction is pinned as a negative case in the same spec as the positive one.3. A closed struct in union-branch position swallowed keys the union could hold
The Microsoft Graph shape
anyOf: [$ref User, {type: object, nullable: true}], withUserstatingadditionalProperties: falseand all-optional fields, still round-tripped{"user":{"free":"shape"}}as{"user":{}}after fixes 1 and 2.Usermatched any object, won the branch ahead of the map, and ate the key — even though the document forbids that key onUser.This required splitting a conflated model first.
ObjectAdditionalProperties::Forbiddenmeant two different things:ForbiddenmeantadditionalProperties: falseKeying strictness on
Forbiddenwould have made 59k open objects reject keys the spec permits. Split intoDenied(stated) andClosed(projected); the 16 read sites that only care whether a catch-all field exists now say so viais_open().#[serde(deny_unknown_fields)]is emitted only when all three hold: the document stated it (Denied), the struct is reachable as an untagged union branch, and there is no flattened variant — serde rejectsdeny_unknown_fieldsalongsideflattenat compile time. A closed struct outside any union has no branch to lose and stays tolerant, so generated clients keep working when a server adds a field.Measured, not assumed
Zero of 19,019 scanned generated struct blocks pair
deny_unknown_fieldswithflatten, and anthropic's generated module — the highest concentration — compiles.Verification
Three new test files, each pairing codegen assertions with a scratch crate that compiles the generated code and round-trips real JSON:
unconstrained_object_union_member_test.rs— every member of the issue's union round-trips; shaped andadditionalProperties: falsemembers keep their structs.opaque_object_union_branch_test.rs— a string reachesString(_)past the object branch; a typeless branch staysserde_json::Value.closed_union_branch_strictness_test.rs— the attribute lands on exactly one of three structs; an undeclared key reaches the open branch and survives; a closed standalone struct still tolerates a newly added server field.cargo test --all-features(686 tests, 0 failures),cargo clippy --all-features -- -D warnings, andcargo fmt --checkare clean.One pinned test changed behavior:
recoverable_typing_test.rs::odata_nullable_reference_union_keeps_its_literal_object_branch. Its intent — don't collapse the union, keep a dynamic branch — is unchanged; only the carrier's spelling moves. I confirmed the explicitnullthat branch admits still hydrates before touching the assertion.Deliberately not changed
additionalPropertiesstill drops undeclared keys on valid input ({"open":{"zzz":1}}→{"open":{}}). That is the generator's documented closed-model projection; preserving those keys means adding a flatten carrier to ~59k corpus schemas. A pre-existing tradeoff, not a regression.deny_unknown_fields. They are selected by tag value, so branch laxness does not misroute them the same way.🤖 Generated with Claude Code
https://claude.ai/code/session_012mBAVbYp2iUSL8VwpyDf19