fix(schema): keep oneOf/anyOf object alternatives as a distinct union - #3012
Open
schani wants to merge 6 commits into
Open
fix(schema): keep oneOf/anyOf object alternatives as a distinct union#3012schani wants to merge 6 commits into
schani wants to merge 6 commits into
Conversation
…#2310) A JSON Schema oneOf/anyOf of multiple object schemas was always collapsed into a single class with every property made optional, discarding the mutual-exclusivity the schema expressed. `{"aa":"x","cc":"y"}` would type-check even though it belongs to neither alternative. Root cause: quicktype's union-unification machinery (UnionBuilder/UnifyClasses) assumed at most one object-shaped member per union and always merged multiple object alternatives into one clique. Fix: JSON Schema input now tags standalone oneOf/anyOf object alternatives (not ones nested under allOf, which represent an intersection) as "distinct" union members. UnionType.isCanonical and the union-building machinery honor that tag, keeping such alternatives as separate members of a canonical union instead of merging them. This is gated per-language via TargetLanguage#supportsUnionsWithMultipleObjectTypes, currently enabled for TypeScript, Flow, JavaScript, and JSON Schema output, so other renderers keep their previous (safe) merging behavior unchanged. The TypeScript/Flow renderer additionally adds `key?: never` guards for each alternative's sibling keys so the emitted union type rejects object literals that mix properties from more than one branch. Test coverage: added test/inputs/schema/one-of-objects.schema, mirroring the issue's exact repro, with a valid two-element sample and a one-of-objects.2.fail.one-of.json sample (object mixing properties from both oneOf branches) that must be rejected at runtime. Enabled the new "one-of" feature for typescript/javascript/flow in test/languages.ts. Verified locally: npm run build, npm run test:unit (163 passed), biome lint clean, and QUICKTEST schema fixtures for typescript (65/65, excluding vega-lite.schema/class-with-additional.schema/class-map-union.schema which already fail identically on master, unrelated to this change), javascript, python, golang and rust covering the new fixture plus the adjacent oneOf/union fixtures (union.schema, implicit-one-of.schema, nested-intersection-union.schema, union-list.schema, mutually-recursive.schema, postman-collection.schema). Fixes #2310 Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
Resolve conflicts in the TypeScript/Flow renderers where master added array-type tuple rendering (sourceForArrayType, minMaxItemsForType) and this branch added exclusive-union rendering (sourceForUnionMembers, isUnionExclusive). Both features coexist: keep both new base-renderer methods and merge the import lists. Also add the "one-of" fixture feature to the LanguageFeature type union it is already used in. Co-Authored-By: Claude <noreply@anthropic.com>
… and php The new one-of-objects.schema is a top-level array, which the kotlinx renderer emits as `typealias TopLevel = JsonArray<T>` (invalid Kotlin) and which the php fixture driver does not support. Both languages already skip union.schema for exactly these reasons; add one-of-objects.schema to the same skip lists. Co-Authored-By: Claude <noreply@anthropic.com>
Generated-output differences52 files differ — 22 modified, 30 new, 0 deleted |
Resolve the TypeScript renderer conflict with the empty-object fix and keep exclusive never guards limited to closed oneOf members. Open object members may legally use sibling keys as additional properties, so guarding those keys would reject valid values. Add focused regression coverage.
Resolve the fixture skip-list conflict by retaining both the PR's one-of object fixture exclusions and master's top-level-array exclusions for KotlinX and PHP. This preserves the distinct oneOf/anyOf union behavior alongside current master rendering and fixture behavior.
Generated-output differences53 files differ — 22 modified, 31 new, 0 deleted |
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.
Bug
A JSON Schema
oneOf/anyOfof multiple object schemas was always collapsed into a single interface/class with every property made optional, discarding the mutual-exclusivity the schema expressed.Repro:
{ "type": "array", "items": { "oneOf": [ {"type": "object", "additionalProperties": false, "properties": { "aa": { "type": "string" }, "bb": { "type": "string" }}}, {"type": "object", "additionalProperties": false, "properties": { "cc": { "type": "string" }, "dd": { "type": "string" }}} ] } }Before:
{"aa":"x","cc":"y"}— invalid per the schema (belongs to neitheroneOfbranch) — type-checked and passed runtime validation against this interface.After:
with the array typed as
Array<(PurpleSchema & { "cc"?: never; "dd"?: never }) | (FluffySchema & { "aa"?: never; "bb"?: never })>, and the generated runtimecast/Convertcode rejects objects mixing properties from both branches (each branch is a closed class withadditionalProperties: false, so an object containing a key from the other branch fails every union alternative and throws).Root cause
quicktype's union-unification machinery (
UnionBuilder/UnifyClasses.ts, invoked viaunifyTypes/flattenUnions) assumed at most one object-shaped member per union type, so all object alternatives from aoneOf/anyOfalways got merged into one class/clique with optional properties, regardless of whether the schema intended them to be mutually exclusive.Fix
JSONSchemaInput.tsnow tags each object alternative of a standaloneoneOf/anyOf(i.e. not nested underallOf/combined with siblingpropertiesat the same schema level, which represents an intersection — seeimplicit-one-of.schema) with a newunionMemberDistincttype attribute, and marksoneOfunions themselves asunionIsExclusive.UnionType.isCanonical(Type/Type.ts) and the union-building machinery (UnionBuilder.ts,UnifyClasses.ts) now allow a canonical union to contain multiple object members when they're all tagged distinct, instead of forcing exactly one.TargetLanguage#supportsUnionsWithMultipleObjectTypesgetter (defaultfalse), currently enabled for TypeScript, Flow, JavaScript, and JSON Schema output — the languages whose renderers can represent a union of multiple object types. All other languages keep their previous (safe) merging behavior unchanged.TypeScriptRenderer.ts) adds"key"?: neverguards for each alternative's sibling keys onunionIsExclusiveunions, so the emitted static TypeScript union type also rejects object literals mixing properties from more than one branch, not just the runtime cast.Test coverage
test/inputs/schema/one-of-objects.schema, mirroring the issue's exact repro, with:one-of-objects.1.json— a valid two-element array (one object per branch).one-of-objects.2.fail.one-of.json— an object mixing properties from bothoneOfbranches, which must make the generated program exit nonzero."one-of"fixture feature fortypescript,javascript, andflowintest/languages.tsso the fail sample runs for those languages.Verification
npm run build— passes.npm run test:unit— 163 tests passed.npx biome check .— clean.QUICKTEST=true FIXTURE=schema-typescript script/test <all 65 non-broken schema fixtures>— all pass, including the newone-of-objects.schemaand the adjacentunion.schema,implicit-one-of.schema,nested-intersection-union.schema,union-list.schema,mutually-recursive.schema,postman-collection.schema(verifying no regression to schemas with related but semantically different combinator patterns).vega-lite.schema,class-with-additional.schema, andclass-map-union.schemawere excluded from that sweep because they already fail identically (sametscerrors) on a cleanmastercheckout — confirmed by stashing this change and re-running them, unrelated pre-existing failures.QUICKTEST=true FIXTURE=schema-javascript script/test— new fixture and adjacent ones pass.QUICKTEST=true FIXTURE=schema-python|schema-golang|schema-rust script/test— new fixture and adjacent ones pass, confirming non-opted-in languages are unaffected.schema-csharpcould not be verified locally (dotnetis not installed in this environment); left to CI.Fixes #2310
🤖 Generated with Claude Code