From b548010b5ebba79c18efa22fee517d7b1acb85f0 Mon Sep 17 00:00:00 2001 From: Leonardo Scappatura <95079472+Leonard013@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:33:50 +0200 Subject: [PATCH 1/4] Handle JSON Schema 2020-12 prefixItems tuples JSON Schema draft 2020-12 renamed the array (tuple) form of `items` to `prefixItems`, which is now emitted by Pydantic v2 and Rust's schemars. `makeArrayType()` only read `schema.items`, so a `prefixItems`-based tuple fell through to the generic `any[]` branch and lost its member types; with `--just-types` and a top-level array that produced no output at all. `prefixItems` was also missing from the `needUnion` type-inference predicate. Apply the existing array-form `items` tuple-union logic to `schema.prefixItems` as well, and add `prefixItems` to the type-inference predicate. When `prefixItems` is absent, array handling is unchanged. Adds test/unit/prefix-items-schema.test.ts, which fails before this change and passes after. Fixes #2811 --- .../src/input/JSONSchemaInput.ts | 33 +++++--- test/unit/prefix-items-schema.test.ts | 80 +++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 test/unit/prefix-items-schema.test.ts diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 893893d0ad..0dff0721e8 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -1034,17 +1034,29 @@ async function addTypesInSchema( async function makeArrayType(): Promise { const singularAttributes = singularizeTypeNames(typeAttributes); const items = schema.items; + // JSON Schema 2020-12 renamed the array (tuple) form of `items` to + // `prefixItems`; treat it the same as a draft-07 array-valued + // `items` so tuple schemas produced by e.g. Pydantic v2 and + // schemars are not silently dropped. + const prefixItems = schema.prefixItems; + const tupleItems = Array.isArray(prefixItems) ? prefixItems : items; + const tupleKey = Array.isArray(prefixItems) + ? "prefixItems" + : "items"; let itemType: TypeRef; - if (Array.isArray(items)) { - const itemsLoc = loc.push("items"); - const itemTypes = await arrayMapSync(items, async (item, i) => { - const itemLoc = itemsLoc.push(i.toString()); - return await toType( - checkJSONSchema(item, itemLoc.canonicalRef), - itemLoc, - singularAttributes, - ); - }); + if (Array.isArray(tupleItems)) { + const itemsLoc = loc.push(tupleKey); + const itemTypes = await arrayMapSync( + tupleItems, + async (item, i) => { + const itemLoc = itemsLoc.push(i.toString()); + return await toType( + checkJSONSchema(item, itemLoc.canonicalRef), + itemLoc, + singularAttributes, + ); + }, + ); itemType = typeBuilder.getUnionType( emptyTypeAttributes, new Set(itemTypes), @@ -1229,6 +1241,7 @@ async function addTypesInSchema( schema.properties !== undefined || schema.additionalProperties !== undefined || schema.items !== undefined || + schema.prefixItems !== undefined || schema.required !== undefined || enumArray !== undefined || isConst; diff --git a/test/unit/prefix-items-schema.test.ts b/test/unit/prefix-items-schema.test.ts new file mode 100644 index 0000000000..e9e075c521 --- /dev/null +++ b/test/unit/prefix-items-schema.test.ts @@ -0,0 +1,80 @@ +// Regression test for issue #2811: JSON Schema 2020-12 tuple schemas that use +// `prefixItems` must be handled the same as draft-07's array-valued `items`. +// +// `makeArrayType` in JSONSchemaInput only ever read `schema.items`, so a +// `prefixItems`-based tuple (now emitted by Pydantic v2 and Rust's schemars) +// fell through to the generic `any[]` branch, dropping the tuple's member +// types entirely. `prefixItems` was also missing from the type-inference +// predicate that decides whether a schema needs a union/array type at all, so +// a bare `prefixItems` schema was not even recognized as an array. +// +// The fixture harness cannot cover this cleanly: it round-trips sample data +// through the generated code, and an `any[]` degradation still accepts the +// sample, so the lost tuple types would go unnoticed. Here we assert directly +// that the referenced member types survive in the generated output. + +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +// The `$ref`'d member schemas carry distinctively named properties so we can +// assert they are present in the output rather than collapsed to `any[]`. +const defs = { + Bar: { + type: "object", + properties: { barField: { type: "integer" } }, + required: ["barField"], + }, + Baz: { + type: "object", + properties: { bazField: { type: "string" } }, + required: ["bazField"], + }, +}; + +async function generateTypeScript(schema: object): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ + name: "Foo", + schema: JSON.stringify(schema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "typescript" }); + return result.lines.join("\n"); +} + +describe("JSON Schema prefixItems tuples (issue #2811)", () => { + test("a 2020-12 prefixItems tuple keeps its member types", async () => { + const output = await generateTypeScript({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ $ref: "#/$defs/Bar" }, { $ref: "#/$defs/Baz" }], + $defs: defs, + }); + + // Before the fix this generated a bare `any[]` with no member types. + expect(output).toContain("barField"); + expect(output).toContain("bazField"); + }); + + test("draft-07 array-valued items still works (no regression)", async () => { + const output = await generateTypeScript({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "array", + items: [ + { $ref: "#/definitions/Bar" }, + { $ref: "#/definitions/Baz" }, + ], + definitions: defs, + }); + + expect(output).toContain("barField"); + expect(output).toContain("bazField"); + }); +}); From 792fad4d92ae1cafed52d60ce410ba4ac0c804d3 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 09:54:57 -0400 Subject: [PATCH 2/4] prefixItems: union rest items, add end-to-end schema fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the prefixItems tuple support: - In 2020-12, an object-form `items` next to `prefixItems` describes the rest elements of an open tuple. It was previously dropped; now its type joins the tuple's element union (quicktype models tuples as arrays of a union of the member types). Boolean `items` (`false` closes the tuple, `true` allows anything) is still ignored. - Add `test/inputs/schema/prefix-items.schema` so the feature is covered by the end-to-end fixture tests, mirroring `tuple.schema` but with 2020-12 `prefixItems`, plus an open tuple that also has a rest `items` schema. The `.fail.union.json` sample has a tuple element outside the declared union, so validating languages must reject it — it would be accepted if the tuple degraded to `any[]`, which is exactly the regression this guards against. The unit test keeps only what fixtures cannot express (that `$ref`d member types survive into the generated code) and gains a case for the prefixItems-plus-rest-items union. Co-Authored-By: Claude Fable 5 --- .../src/input/JSONSchemaInput.ts | 19 ++++++++++++ .../schema/prefix-items.1.fail.union.json | 4 +++ test/inputs/schema/prefix-items.1.json | 4 +++ test/inputs/schema/prefix-items.schema | 16 ++++++++++ test/unit/prefix-items-schema.test.ts | 31 ++++++++++++------- 5 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 test/inputs/schema/prefix-items.1.fail.union.json create mode 100644 test/inputs/schema/prefix-items.1.json create mode 100644 test/inputs/schema/prefix-items.schema diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 0dff0721e8..d3a0e4feef 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -1057,6 +1057,25 @@ async function addTypesInSchema( ); }, ); + // In 2020-12 an object-form `items` next to `prefixItems` + // describes the rest elements of an open tuple. quicktype + // models tuples as arrays of a union of the member types, so + // the rest type joins that union. A boolean `items` (`false` + // closes the tuple, `true` allows anything) is ignored. + if ( + tupleKey === "prefixItems" && + typeof items === "object" && + !Array.isArray(items) + ) { + const restItemsLoc = loc.push("items"); + itemTypes.push( + await toType( + checkJSONSchema(items, restItemsLoc.canonicalRef), + restItemsLoc, + singularAttributes, + ), + ); + } itemType = typeBuilder.getUnionType( emptyTypeAttributes, new Set(itemTypes), diff --git a/test/inputs/schema/prefix-items.1.fail.union.json b/test/inputs/schema/prefix-items.1.fail.union.json new file mode 100644 index 0000000000..b0603b2e33 --- /dev/null +++ b/test/inputs/schema/prefix-items.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "tuple": [123, "not-a-tuple-member"], + "open": [false, 11, 13] +} diff --git a/test/inputs/schema/prefix-items.1.json b/test/inputs/schema/prefix-items.1.json new file mode 100644 index 0000000000..4dcd7ebe58 --- /dev/null +++ b/test/inputs/schema/prefix-items.1.json @@ -0,0 +1,4 @@ +{ + "tuple": [123, true], + "open": [false, 11, 13] +} diff --git a/test/inputs/schema/prefix-items.schema b/test/inputs/schema/prefix-items.schema new file mode 100644 index 0000000000..6c6d5e4f19 --- /dev/null +++ b/test/inputs/schema/prefix-items.schema @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tuple": { + "type": "array", + "prefixItems": [{ "type": "integer" }, { "type": "boolean" }] + }, + "open": { + "type": "array", + "prefixItems": [{ "type": "boolean" }], + "items": { "type": "integer" } + } + }, + "required": ["tuple", "open"] +} diff --git a/test/unit/prefix-items-schema.test.ts b/test/unit/prefix-items-schema.test.ts index e9e075c521..307c47b507 100644 --- a/test/unit/prefix-items-schema.test.ts +++ b/test/unit/prefix-items-schema.test.ts @@ -1,17 +1,11 @@ // Regression test for issue #2811: JSON Schema 2020-12 tuple schemas that use // `prefixItems` must be handled the same as draft-07's array-valued `items`. // -// `makeArrayType` in JSONSchemaInput only ever read `schema.items`, so a -// `prefixItems`-based tuple (now emitted by Pydantic v2 and Rust's schemars) -// fell through to the generic `any[]` branch, dropping the tuple's member -// types entirely. `prefixItems` was also missing from the type-inference -// predicate that decides whether a schema needs a union/array type at all, so -// a bare `prefixItems` schema was not even recognized as an array. -// -// The fixture harness cannot cover this cleanly: it round-trips sample data -// through the generated code, and an `any[]` degradation still accepts the -// sample, so the lost tuple types would go unnoticed. Here we assert directly -// that the referenced member types survive in the generated output. +// End-to-end coverage lives in the fixture test +// `test/inputs/schema/prefix-items.schema` (with a `.fail.union.json` sample +// that catches an `any[]` degradation). What fixtures cannot express is that +// `$ref`d tuple member types survive into the generated code, so we assert +// that directly here. import { FetchingJSONSchemaStore, @@ -63,6 +57,21 @@ describe("JSON Schema prefixItems tuples (issue #2811)", () => { expect(output).toContain("bazField"); }); + test("an object-form `items` next to `prefixItems` joins the union", async () => { + const output = await generateTypeScript({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ $ref: "#/$defs/Bar" }], + items: { $ref: "#/$defs/Baz" }, + $defs: defs, + }); + + // The rest (`items`) type must not be dropped: both the prefix + // member and the rest member appear in the element union. + expect(output).toContain("barField"); + expect(output).toContain("bazField"); + }); + test("draft-07 array-valued items still works (no regression)", async () => { const output = await generateTypeScript({ $schema: "http://json-schema.org/draft-07/schema#", From 3a0087792997f241eade7e66a54d8aa65239a9da Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 10:11:52 -0400 Subject: [PATCH 3/4] Skip prefix-items.schema for cjson The generated cJSON deserializer silently drops array elements whose type is outside the union instead of aborting parsing, so the prefix-items.1.fail.union.json expected-failure sample does not fail. This is the already-documented "Union, Map and Arrays with invalid types are not checked" limitation; add the schema to that skip group. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index e268fae95f..6f7f4eea26 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -547,6 +547,7 @@ export const CJSONLanguage: Language = { "go-schema-pattern-properties.schema", "multi-type-enum.schema", "nested-intersection-union.schema", + "prefix-items.schema", /* Constraints (min/max and regex) are not supported (for the current implementation, can be added later, should abord parsing and return NULL) */ "minmaxlength.schema", "optional-const-ref.schema", From 2c644bf02c34c25643ba0f31118e9a1ddbac9948 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 10:36:53 -0400 Subject: [PATCH 4/4] Skip prefix-items.schema for scala3 and haskell Both test drivers exit 0 on a failed decode (scala3 prints the circe DecodingFailure, haskell encodes the Maybe result as null), so the prefix-items.1.fail.union.json expected-failure sample cannot be detected. Same documented limitation as nested-intersection-union.schema. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 6f7f4eea26..9ed081dc8b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1022,6 +1022,7 @@ I havea no idea how to encode these tests correctly. // The test driver prints the circe DecodingFailure and exits 0, so // expected-failure samples cannot be detected. "nested-intersection-union.schema", + "prefix-items.schema", "date-time-or-string.schema", "implicit-one-of.schema", "go-schema-pattern-properties.schema", @@ -1467,6 +1468,7 @@ export const HaskellLanguage: Language = { // The test driver encodes the Maybe result, so a failed decode prints // "null" and exits 0 — expected-failure samples cannot be detected. "nested-intersection-union.schema", + "prefix-items.schema", "direct-union.schema", ...skipsEnumValueValidation, "go-schema-pattern-properties.schema",