Skip to content

fix(typescript): preserve anyOf unions of object types instead of merging#3030

Open
schani wants to merge 7 commits into
masterfrom
agent/fix-issue-1338
Open

fix(typescript): preserve anyOf unions of object types instead of merging#3030
schani wants to merge 7 commits into
masterfrom
agent/fix-issue-1338

Conversation

@schani

@schani schani commented Jul 20, 2026

Copy link
Copy Markdown
Member

Bug

With JSON Schema and the TypeScript target, anyOf of two plain object
types did not turn into a TypeScript union. Instead the two object types
were merged/flattened into a single interface containing all properties
from both, and the referencing property was typed as that merged
interface instead of a union.

Repro schema:

{
  "$ref": "#/definitions/TestObject",
  "definitions": {
    "TestObject": {
      "type": "object",
      "properties": {
        "firstProperty": {
          "anyOf": [{ "$ref": "#/definitions/MyObjectA" }, { "$ref": "#/definitions/MyObjectB" }]
        }
      }
    },
    "MyObjectA": { "type": "object", "properties": { "a": {"type":"string"}, "b": {"type":"number"}, "c": {"type":"boolean"} } },
    "MyObjectB": { "type": "object", "properties": { "d": {"type":"string"}, "e": {"type":"number"}, "f": {"type":"boolean"} } }
  }
}

node dist/index.js --lang typescript --src-lang schema test.schema produced:

export interface Test {
    firstProperty?: MyObject; // wrong: merged interface with a-f
}
export interface MyObject { a?: string; b?: number; c?: boolean; d?: string; e?: number; f?: boolean; }

instead of the expected

export interface Test {
    firstProperty?: MyObjectA | MyObjectB;
}

Root cause

flattenUnions (packages/quicktype-core/src/rewrites/FlattenUnions.ts)
always collapsed non-canonical unions whose members were class/object
types via UnifyUnionBuilder/UnifyClasses, regardless of whether the
target language could actually represent a union of named object types.
TypeScript can (and does, for e.g. array | object anyOf combinations),
but the class/object-union case was always flattened.

Fix

  • Added TargetLanguage.supportsUnionsWithMultipleObjectTypes
    (packages/quicktype-core/src/TargetLanguage.ts), defaulting to
    false, and enabled it for TypeScript
    (packages/quicktype-core/src/language/TypeScriptFlow/language.ts).
  • flattenUnions now skips flattening a non-canonical union when all its
    members are class/object types and the target language supports
    unions of object types — except when that union still participates in
    an IntersectionType, since IntersectionAccumulator can only
    represent one member per kind and still needs the union flattened
    there.
  • Threaded the new flag through Run.ts to flattenUnions.

This only changes behavior for target languages that opt in
(TypeScript); all other languages keep flattening class unions exactly
as before.

Test coverage

Added a new JSON Schema fixture:

  • test/inputs/schema/anyof-object-union.schema — the anyOf-of-two-
    objects scenario from the issue (plus an array/object anyOf, which
    already worked, to guard against regressing that case).
  • test/inputs/schema/anyof-object-union.1.json — a passing round-trip
    sample.
  • test/inputs/schema/anyof-object-union.1.fail.class-union.json — a
    sample mixing properties from both object types, which must be
    rejected now that the two objects are validated as a real union
    (additionalProperties: false on both).

Wired up via a new "class-union" fixture feature enabled for the
typescript language in test/languages.ts, so the .fail.class-union
sample is exercised for schema-typescript.

Verification (local)

  • npm run build passes
  • QUICKTEST=true FIXTURE=schema-typescript script/test: 71/71 passed,
    including the new fixture and its fail sample
  • QUICKTEST=true FIXTURE=schema-python script/test: 71/71 passed
    (confirms non-opted-in languages are unaffected)
  • QUICKTEST=true FIXTURE=schema-golang script/test: 71/71 passed
  • npm run test:unit: 170/170 passed
  • Direct repro now emits firstProperty?: MyObjectA | MyObjectB;
    instead of a merged MyObject interface

CI will run the full fixture/unit suite across all languages.

Fixes #1338

🤖 Generated with Claude Code

schani and others added 6 commits July 20, 2026 18:05
…ging (#1338)

JSON Schema `anyOf` of two plain object types was flattened by
`flattenUnions` into a single merged interface with all properties from
both objects, instead of producing a proper TypeScript union of the two
named types. `secondProperty`-style unions (array | object) already
worked correctly; only object/object unions were affected.

Root cause: `flattenUnions` always collapsed non-canonical unions of
class/object types via `UnifyUnionBuilder`, regardless of whether the
target language could represent a union of named object types.

Fix: add `TargetLanguage.supportsUnionsWithMultipleObjectTypes`
(default `false`, enabled for TypeScript) and skip flattening
non-canonical unions whose members are all class/object types when the
target language supports it, except where the union still participates
in an intersection (IntersectionAccumulator can only hold one member per
kind).

Test coverage: new JSON Schema fixture
`test/inputs/schema/anyof-object-union.schema` with a passing sample and
a `.fail.class-union` sample (mixed properties from both object types,
which should fail since additionalProperties is false), wired into the
"class-union" feature enabled for the `typescript` fixture.

Verified locally:
- `npm run build` passes
- `QUICKTEST=true FIXTURE=schema-typescript script/test`: 71/71 passed
  (new fixture included)
- `QUICKTEST=true FIXTURE=schema-python script/test`: 71/71 passed
  (regression check for languages without the new capability)
- `QUICKTEST=true FIXTURE=schema-golang script/test`: 71/71 passed
- `npm run test:unit`: 170/170 passed
- Direct repro now emits `firstProperty?: MyObjectA | MyObjectB;`
  instead of a merged `MyObject` interface

Fixes #1338

Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
The new anyof-object-union.schema has a secondProperty that is an
array|object union. The kotlinx renderer emits such unions as a sealed
class without serializer wiring, so it fails to deserialize at runtime
("Class discriminator was missing ... polymorphic scope of
'SecondProperty'") — the same documented limitation already covered by
kotlinx's other union skips. Add the schema to kotlinx's skipSchema list.

This is a pre-existing kotlinx limitation, not caused by the PR's
TypeScript-only flattenUnions change (kotlinx still merges the object
union in firstProperty as before). Verified the fixture round-trips for
rust locally, and cjson/golang/dart/flow/php/javascript/ruby passed in CI.

Co-Authored-By: Claude <noreply@anthropic.com>
…g constructor-matching limitation) (#undefined)

Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
test/unit/cjson-enum-default.test.ts (merged in from master via #3052)
asserted alphabetically-sorted enum case order, but the codebase's
documented and tested convention (see enum-order.test.ts) is to preserve
JSON Schema declaration order. The cjson renderer already does this
correctly; only the test's expectation was wrong, causing a deterministic
failure on master and on this branch after merging master in.

Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Generated-output differences

45 files differ — 3 modified, 42 new, 0 deleted
5801 changed lines — +5775 / −26

Open the generated-output report →

@github-actions

Copy link
Copy Markdown

Generated-output differences

46 files differ — 3 modified, 43 new, 0 deleted
5957 changed lines — +5931 / −26

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.

[Typescript, JSON Schema] No union type with anyOf

1 participant