Skip to content

fix(core): preserve outer oneOf title when flattened with null - #2980

Open
schani wants to merge 5 commits into
masterfrom
agent/fix-issue-2410
Open

fix(core): preserve outer oneOf title when flattened with null#2980
schani wants to merge 5 commits into
masterfrom
agent/fix-issue-2410

Conversation

@schani

@schani schani commented Jul 20, 2026

Copy link
Copy Markdown
Member

Bug

A JSON Schema oneOf union of single-value string enums that carries its
own title (e.g. the "enum of enums" shape Rust's schemars emits) loses
that title when the union is made nullable via an enclosing anyOf with
{ "type": "null" }. quicktype instead names the generated type after the
first variant's title.

Repro:

{
  "properties": {
    "format": {
      "anyOf": [{ "$ref": "#/definitions/DataFormat" }, { "type": "null" }]
    }
  },
  "definitions": {
    "DataFormat": {
      "title": "DataFormat",
      "oneOf": [
        { "title": "Turtle", "type": "string", "enum": ["turtle"] },
        { "title": "RDF XML", "type": "string", "enum": ["rdf_xml"] },
        { "title": "N-Triples", "type": "string", "enum": ["n_triples"] },
        { "title": "N-Quads", "type": "string", "enum": ["n_quads"] }
      ]
    }
  }
}
node dist/index.js -s schema schema.json --lang ts --prefer-types -o out.ts

Before:

format: Turtle | null;
export type Turtle = "turtle" | "rdf_xml" | "n_triples" | "n_quads";

After:

format: DataFormat | null;
export type DataFormat = "turtle" | "rdf_xml" | "n_triples" | "n_quads";

Root cause

When the inline DataFormat union is flattened alongside null from the
enclosing anyOf, UnionBuilder's attributesForTypes collapses the
nested homogeneous union into its single non-null member type, but the
title attribute carried by that nested union stayed attached to the
discarded nullable-union node instead of the collapsed-into type. Naming
then fell back to the first variant's own title. This affected every output
language (verified TypeScript and Go), since the defect is in
quicktype-core, not a renderer.

Fix

packages/quicktype-core/src/UnionBuilder.ts: when computing attributes for
types in attributesForTypes, detect nested unions that are homogeneous
(all members share one kind) and sit alongside only null in an enclosing
union — i.e. unions that get fully absorbed into a single non-null type
during unification — and fold their own attributes (title, description,
etc.) onto that resulting type, at increased distance so more specific
names still win where applicable.

Tests

  • test/inputs/schema/enum-of-enums-null.schema (+ .1.json/.2.json
    samples): a new end-to-end JSON Schema fixture reproducing the exact
    reported shape, picked up automatically by the schema fixture harness for
    every target language.
  • test/unit/enum-of-enums-null-name.test.ts: a focused unit test asserting
    the generated TypeScript names the type DataFormat and never falls back
    to Turtle, since fixture round-tripping alone can't assert on the chosen
    identifier.

Verification

  • npm run build passes.
  • npm run test:unit — 164/164 passing.
  • Repro re-run locally for both --lang ts and --lang go: both now emit
    DataFormat instead of Turtle.
  • QUICKTEST=true FIXTURE=schema-golang script/test — 68/68 passing,
    including the new enum-of-enums-null.schema fixture.
  • QUICKTEST=true FIXTURE=schema-typescript script/test — passing except a
    single pre-existing, unrelated failure on vega-lite.schema (reproduces
    identically on master without this change; a noUncheckedIndexedAccess-style
    strict-mode TS2411 clash unrelated to unions/enums). The new
    enum-of-enums-null.schema fixture passes.
  • CI will run the full fixture matrix across all languages.

Fixes #2410

🤖 Generated with Claude Code

schani and others added 2 commits July 20, 2026 16:56
A `oneOf` union of single-value string enums that carries its own
`title` (e.g. a Rust schemars "enum of enums") lost that title when
wrapped in an `anyOf` with `{ "type": "null" }`. FlattenUnions/
UnionBuilder collapsed the nested homogeneous union into the single
non-null member type, but the outer union's title attribute stayed on
the discarded nullable union, so naming fell back to the first
variant's title (e.g. `Turtle`) instead of the intended union title
(e.g. `DataFormat`).

Fixed by attributing a nested homogeneous union's own title/attributes
to the type it collapses into whenever it sits alongside null in the
enclosing union, in UnionBuilder.ts's attributesForTypes.

Added test/inputs/schema/enum-of-enums-null.schema (+ .1.json/.2.json
samples) covering the exact reported shape as an end-to-end schema
fixture, plus test/unit/enum-of-enums-null-name.test.ts asserting the
generated TypeScript names the type DataFormat rather than Turtle.

Fixes #2410

Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
Co-Authored-By: Claude <noreply@anthropic.com>
schani and others added 3 commits July 20, 2026 20:27
The enum-of-enums-null.schema fail sample relies on rejecting an invalid
enum value, which languages that do not validate enum values (cjson,
crystal, swift, haskell, typescript-zod, typescript-effect-schema) do not
enforce. Add the schema to skipsEnumValueValidation so those languages
skip it, matching the existing enum.schema pattern. Validating languages
still run both positive and negative cases.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Generated-output differences

31 files differ — 0 modified, 31 new, 0 deleted
2799 changed lines — +2799 / −0

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.

Wrong enum name when using union with null

2 participants