Skip to content

fix(schema): keep oneOf/anyOf object alternatives as a distinct union - #3012

Open
schani wants to merge 6 commits into
masterfrom
agent/fix-issue-2310
Open

fix(schema): keep oneOf/anyOf object alternatives as a distinct union#3012
schani wants to merge 6 commits into
masterfrom
agent/fix-issue-2310

Conversation

@schani

@schani schani commented Jul 20, 2026

Copy link
Copy Markdown
Member

Bug

A JSON Schema oneOf/anyOf of 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" }}}
     ]
  }
}
quicktype --lang typescript --src-lang schema schema.json

Before:

export interface PurpleArray {
    aa?: string;
    bb?: string;
    cc?: string;
    dd?: string;
}

{"aa":"x","cc":"y"} — invalid per the schema (belongs to neither oneOf branch) — type-checked and passed runtime validation against this interface.

After:

export interface PurpleSchema {
    aa?: string;
    bb?: string;
}

export interface FluffySchema {
    cc?: string;
    dd?: string;
}

with the array typed as Array<(PurpleSchema & { "cc"?: never; "dd"?: never }) | (FluffySchema & { "aa"?: never; "bb"?: never })>, and the generated runtime cast/Convert code rejects objects mixing properties from both branches (each branch is a closed class with additionalProperties: 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 via unifyTypes/flattenUnions) assumed at most one object-shaped member per union type, so all object alternatives from a oneOf/anyOf always got merged into one class/clique with optional properties, regardless of whether the schema intended them to be mutually exclusive.

Fix

  • JSONSchemaInput.ts now tags each object alternative of a standalone oneOf/anyOf (i.e. not nested under allOf/combined with sibling properties at the same schema level, which represents an intersection — see implicit-one-of.schema) with a new unionMemberDistinct type attribute, and marks oneOf unions themselves as unionIsExclusive.
  • 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.
  • This is gated per target language via a new TargetLanguage#supportsUnionsWithMultipleObjectTypes getter (default false), 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.
  • The TypeScript/Flow renderer (TypeScriptRenderer.ts) adds "key"?: never guards for each alternative's sibling keys on unionIsExclusive unions, 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

  • Added 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 both oneOf branches, which must make the generated program exit nonzero.
  • Enabled the new "one-of" fixture feature for typescript, javascript, and flow in test/languages.ts so 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 new one-of-objects.schema and the adjacent union.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, and class-map-union.schema were excluded from that sweep because they already fail identically (same tsc errors) on a clean master checkout — 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-csharp could not be verified locally (dotnet is not installed in this environment); left to CI.

Fixes #2310

🤖 Generated with Claude Code

schani and others added 4 commits July 20, 2026 17:30
…#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>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Generated-output differences

52 files differ — 22 modified, 30 new, 0 deleted
13930 changed lines — +9570 / −4360

Open the generated-output report →

schani and others added 2 commits July 22, 2026 16:27
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.
@github-actions

Copy link
Copy Markdown

Generated-output differences

53 files differ — 22 modified, 31 new, 0 deleted
14003 changed lines — +9643 / −4360

Open the generated-output report →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSONSchema: "OneOf" in "Array.Items" is ignored

2 participants