From 37b2f0aa9d3390a14f7e7e26b5473356fd68f026 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 16:56:29 -0400 Subject: [PATCH 1/3] fix(core): preserve outer oneOf title when flattened with null (#2410) 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 --- packages/quicktype-core/src/UnionBuilder.ts | 41 ++++++++++++++- test/inputs/schema/enum-of-enums-null.1.json | 3 ++ test/inputs/schema/enum-of-enums-null.2.json | 3 ++ test/inputs/schema/enum-of-enums-null.schema | 48 +++++++++++++++++ test/unit/enum-of-enums-null-name.test.ts | 55 ++++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 test/inputs/schema/enum-of-enums-null.1.json create mode 100644 test/inputs/schema/enum-of-enums-null.2.json create mode 100644 test/inputs/schema/enum-of-enums-null.schema create mode 100644 test/unit/enum-of-enums-null-name.test.ts diff --git a/packages/quicktype-core/src/UnionBuilder.ts b/packages/quicktype-core/src/UnionBuilder.ts index 611a147858..7bbd24289f 100644 --- a/packages/quicktype-core/src/UnionBuilder.ts +++ b/packages/quicktype-core/src/UnionBuilder.ts @@ -405,6 +405,26 @@ function attributesForTypes( traverse(t, rootPath, typesArray.length === 1); } + // A nested homogeneous union next to null becomes one non-null type during + // unification, so its attributes belong on that type rather than on the + // enclosing nullable union. + const allDescendants = defined(typesForUnion.get(rootPath[0])); + const nullableUnionsCollapsedIntoType = new Set( + Array.from(unions).filter((u) => { + if (unionsEquivalentToRoot.has(u)) return false; + const descendants = defined(typesForUnion.get(u)); + const otherDescendants = Array.from(allDescendants).filter( + (t) => !descendants.has(t), + ); + return ( + descendants.size > 1 && + new Set(Array.from(descendants, (t) => t.kind)).size === 1 && + otherDescendants.length > 0 && + otherDescendants.every((t) => t.kind === "null") + ); + }), + ); + const resultAttributes = mapMap(unionsForType, (unionForType, t) => { const singleAncestors = Array.from(unionForType).filter( (u) => defined(typesForUnion.get(u)).size === 1, @@ -416,14 +436,31 @@ function attributesForTypes( const inheritedAttributes = singleAncestors.map((u) => u.getAttributes(), ); - return combineTypeAttributes( + let attributes = combineTypeAttributes( "union", [t.getAttributes()].concat(inheritedAttributes), ); + const collapsedAncestors = Array.from(unionForType).filter( + (u): u is UnionType => + u instanceof UnionType && + nullableUnionsCollapsedIntoType.has(u), + ); + for (const u of collapsedAncestors.reverse()) { + attributes = combineTypeAttributes( + "union", + u.getAttributes(), + increaseTypeAttributesDistance(attributes), + ); + } + + return attributes; }); const unionAttributes = Array.from(unions).map((u) => { const t = typesForUnion.get(u); - if (t !== undefined && t.size === 1) { + if ( + (t !== undefined && t.size === 1) || + nullableUnionsCollapsedIntoType.has(u) + ) { return emptyTypeAttributes; } diff --git a/test/inputs/schema/enum-of-enums-null.1.json b/test/inputs/schema/enum-of-enums-null.1.json new file mode 100644 index 0000000000..ef801982c8 --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.1.json @@ -0,0 +1,3 @@ +{ + "format": "rdf_xml" +} diff --git a/test/inputs/schema/enum-of-enums-null.2.json b/test/inputs/schema/enum-of-enums-null.2.json new file mode 100644 index 0000000000..b0686c6047 --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.2.json @@ -0,0 +1,3 @@ +{ + "format": null +} diff --git a/test/inputs/schema/enum-of-enums-null.schema b/test/inputs/schema/enum-of-enums-null.schema new file mode 100644 index 0000000000..790d459a86 --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.schema @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConstructResponse", + "type": "object", + "required": ["format"], + "properties": { + "format": { + "description": "The format of the data.", + "anyOf": [ + { "$ref": "#/definitions/DataFormat" }, + { "type": "null" } + ] + } + }, + "additionalProperties": false, + "definitions": { + "DataFormat": { + "title": "DataFormat", + "description": "The format of the data.", + "oneOf": [ + { + "title": "Turtle", + "description": "Turtle format", + "type": "string", + "enum": ["turtle"] + }, + { + "title": "RDF XML", + "description": "RDF XML format", + "type": "string", + "enum": ["rdf_xml"] + }, + { + "title": "N-Triples", + "description": "N-Triples format", + "type": "string", + "enum": ["n_triples"] + }, + { + "title": "N-Quads", + "description": "N-Quads format", + "type": "string", + "enum": ["n_quads"] + } + ] + } + } +} diff --git a/test/unit/enum-of-enums-null-name.test.ts b/test/unit/enum-of-enums-null-name.test.ts new file mode 100644 index 0000000000..048ccf5ef2 --- /dev/null +++ b/test/unit/enum-of-enums-null-name.test.ts @@ -0,0 +1,55 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { expect, test } from "vitest"; + +const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + required: ["format"], + 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"] }, + ], + }, + }, +}); + +// The fixture harness verifies round-tripping, but generated identifiers are +// self-consistent even when the wrong title is chosen. Assert the emitted name +// directly so the outer oneOf title cannot regress to the first variant title. +test("uses the outer oneOf title for a nullable enum-of-enums", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "Out", schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "prefer-types": true }, + }); + const output = result.lines.join("\n"); + + expect(output).toContain("format: DataFormat | null;"); + expect(output).toContain( + 'export type DataFormat = "turtle" | "rdf_xml" | "n_triples" | "n_quads";', + ); + expect(output).not.toMatch(/export type Turtle\b/); +}); From cc3c4bb38eac4c3d7e59ccffbc6347584200b75c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:43:32 -0400 Subject: [PATCH 2/3] test: add missing fixture cases for enum-of-enums-null.schema (#2980) Co-Authored-By: Claude --- test/inputs/schema/enum-of-enums-null.1.fail.enum.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 test/inputs/schema/enum-of-enums-null.1.fail.enum.json diff --git a/test/inputs/schema/enum-of-enums-null.1.fail.enum.json b/test/inputs/schema/enum-of-enums-null.1.fail.enum.json new file mode 100644 index 0000000000..5ab1864c3e --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.1.fail.enum.json @@ -0,0 +1,3 @@ +{ + "format": "xml" +} From 5098d6e51ae99735f2e7c31d326ed95ea25e098f Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:27:47 -0400 Subject: [PATCH 3/3] test: fix gating of enum-of-enums-null fixture cases (#2980) 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 --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..bdcc4192a0 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -25,6 +25,7 @@ const skipsEnumValueValidation = [ "enum-large.schema", "optional-enum.schema", "const-non-string.schema", + "enum-of-enums-null.schema", ]; // The language makes no int/double distinction in unions (e.g. an integer is