From e60f09c0daafaf459b01c9404f896f3d32aaa97c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:12:24 -0400 Subject: [PATCH 1/5] fix(typescript-flow): emit type aliases for top-level non-object types (#1122) With --just-types, a top-level array or map type (e.g. echo '[1,2,3.14]' | quicktype --lang ts --just-types --top-level Foo) silently emitted no output. The TypeScript/Flow renderer only emitted a top-level alias when the top-level type was primitive; arrays and maps fell through with nothing emitted. Named types (interfaces/enums/unions) were fine since they get their own declaration elsewhere. Fix emitTypes() to emit a top-level alias whenever the top-level type has no directly-reachable named type of its own (using the existing directlyReachableSingleNamedType helper, also used by other renderers for the analogous decision), instead of only checking t.isPrimitive(). Also add the "export" keyword to match the export interface/export enum/export type convention used elsewhere in this renderer. Test coverage: - test/unit/typescript-flow-top-level-alias.test.ts: asserts the emitted top-level alias text for TS/Flow top-level arrays and maps, and that a map whose value type claims the top-level name isn't double-declared. - test/inputs/schema/empty-object.schema (+ .1.json): a bare top-level map JSON Schema fixture input, enabled for typescript/flow; skipped for renderers (JavaScript PropTypes, JavaScript, PHP, TypeScript Zod, TypeScript Effect Schema) that don't support a bare top-level map. Verified locally: npm run build, npm run test:unit (176 passed), and the schema-typescript and typescript fixtures (QUICKTEST=true) all pass. CI will additionally validate the other language fixtures. Co-Authored-By: gpt-5.6-sol via pi --- .../TypeScriptFlowBaseRenderer.ts | 18 ++++- test/inputs/schema/empty-object.1.json | 1 + test/inputs/schema/empty-object.schema | 5 ++ test/languages.ts | 16 +++- .../typescript-flow-top-level-alias.test.ts | 79 +++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 test/inputs/schema/empty-object.1.json create mode 100644 test/inputs/schema/empty-object.schema create mode 100644 test/unit/typescript-flow-top-level-alias.test.ts diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index efcdbb2cee..53f9c8d3e8 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -19,7 +19,11 @@ import { type Type, UnionType, } from "../../Type/index.js"; -import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; +import { + directlyReachableSingleNamedType, + matchType, + nullableFromUnion, +} from "../../Type/TypeUtils.js"; import { JavaScriptRenderer, type JavaScriptTypeAnnotations, @@ -184,15 +188,21 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { } protected emitTypes(): void { - // emit primitive top levels + // Emit top levels whose name isn't used by forEachNamedType below. this.forEachTopLevel("none", (t, name) => { - if (!t.isPrimitive()) { + if (directlyReachableSingleNamedType(t) !== undefined) { return; } this.ensureBlankLine(); this.emitDescription(this.descriptionForType(t)); - this.emitLine("type ", name, " = ", this.sourceFor(t).source, ";"); + this.emitLine( + "export type ", + name, + " = ", + this.sourceFor(t).source, + ";", + ); }); this.forEachNamedType( diff --git a/test/inputs/schema/empty-object.1.json b/test/inputs/schema/empty-object.1.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/test/inputs/schema/empty-object.1.json @@ -0,0 +1 @@ +{} diff --git a/test/inputs/schema/empty-object.schema b/test/inputs/schema/empty-object.schema new file mode 100644 index 0000000000..af61a2a0d7 --- /dev/null +++ b/test/inputs/schema/empty-object.schema @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": {} +} diff --git a/test/languages.ts b/test/languages.ts index 7f80f5e3b1..70be76a828 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1089,7 +1089,10 @@ export const JavaScriptPropTypesLanguage: Language = { "spotify-album.json", // renderer does not support recursion "76ae1.json", // renderer does not support recursion ], - skipSchema: [], + skipSchema: [ + // The renderer does not support a bare top-level map. + "empty-object.schema", + ], skipMiscJSON: false, rendererOptions: { "module-system": "es6" }, quickTestRendererOptions: [{ converters: "top-level" }], @@ -1287,7 +1290,10 @@ I havea no idea how to encode these tests correctly. "php-mixed-union.json", "nst-test-suite.json", ], - skipSchema: [], + skipSchema: [ + // The renderer does not support a bare top-level map. + "empty-object.schema", + ], skipMiscJSON: false, rendererOptions: { "just-types": "true" }, quickTestRendererOptions: [], @@ -1853,6 +1859,8 @@ export const PHPLanguage: Language = { ], skipMiscJSON: true, skipSchema: [ + // The renderer does not support a bare top-level map. + "empty-object.schema", // PHP class names are case-insensitive, but the namer dedups // case-sensitively, so this declares classes that collide (same // reason Java and Python skip it). @@ -1965,6 +1973,8 @@ export const TypeScriptZodLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + // The renderer does not support a bare top-level map. + "empty-object.schema", "any.schema", ...skipsUntypedUnions, "direct-union.schema", @@ -2086,6 +2096,8 @@ export const TypeScriptEffectSchemaLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + // The renderer does not support a bare top-level map. + "empty-object.schema", "any.schema", ...skipsUntypedUnions, "direct-union.schema", diff --git a/test/unit/typescript-flow-top-level-alias.test.ts b/test/unit/typescript-flow-top-level-alias.test.ts new file mode 100644 index 0000000000..0f04d82da3 --- /dev/null +++ b/test/unit/typescript-flow-top-level-alias.test.ts @@ -0,0 +1,79 @@ +// Top-level array/map schema fixtures exercise their generated converters, but +// those converters inline the collection type, so they still compile when the +// public top-level alias is missing. Assert the declaration itself here to +// prevent that regression. + +import { + InputData, + JSONSchemaInput, + type LanguageName, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function renderSchema( + lang: LanguageName, + name: string, + schema: object, +): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name, schema: JSON.stringify(schema) }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang, + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +describe("TypeScript/Flow unnamed top-level aliases", () => { + test.each([ + ["typescript", "unknown"], + ["flow", "mixed"], + ] as const)("%s emits a top-level map alias", async (lang, anyType) => { + const output = await renderSchema(lang, "Values", { + type: "object", + additionalProperties: {}, + }); + + expect(output).toContain( + `export type Values = { [key: string]: ${anyType} };`, + ); + }); + + test.each(["typescript", "flow"] as const)( + "%s emits a top-level array alias", + async (lang) => { + const output = await renderSchema(lang, "Values", { + type: "array", + items: { type: "number" }, + }); + + expect(output).toContain("export type Values = number[];"); + }, + ); + + test.each(["typescript", "flow"] as const)( + "%s does not alias a map whose value type claims the top-level name", + async (lang) => { + const output = await renderSchema(lang, "TopLevel", { + type: "object", + additionalProperties: { + type: "object", + properties: { + one: { type: "integer" }, + two: { type: "boolean" }, + }, + required: ["one", "two"], + }, + }); + const declarations = + output.match(/export (?:type|interface) TopLevel\b/g) ?? []; + + expect(declarations).toHaveLength(1); + }, + ); +}); From 86596a59c9b5190f78535e630333753478a89a6c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:37:56 -0400 Subject: [PATCH 2/5] test: add missing fixture cases for empty-object.schema (#3035) Co-Authored-By: Claude --- test/inputs/schema/empty-object.1.fail.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 test/inputs/schema/empty-object.1.fail.json diff --git a/test/inputs/schema/empty-object.1.fail.json b/test/inputs/schema/empty-object.1.fail.json new file mode 100644 index 0000000000..b5d8bb58d9 --- /dev/null +++ b/test/inputs/schema/empty-object.1.fail.json @@ -0,0 +1 @@ +[1, 2, 3] From 2118a062afd9300019635eb1f878f041b91355e2 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:01:28 -0400 Subject: [PATCH 3/5] style: fix Biome formatting in typescript-flow top-level alias test Co-Authored-By: Claude --- .../typescript-flow-top-level-alias.test.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/test/unit/typescript-flow-top-level-alias.test.ts b/test/unit/typescript-flow-top-level-alias.test.ts index 0f04d82da3..13173588fe 100644 --- a/test/unit/typescript-flow-top-level-alias.test.ts +++ b/test/unit/typescript-flow-top-level-alias.test.ts @@ -44,36 +44,36 @@ describe("TypeScript/Flow unnamed top-level aliases", () => { ); }); - test.each(["typescript", "flow"] as const)( - "%s emits a top-level array alias", - async (lang) => { - const output = await renderSchema(lang, "Values", { - type: "array", - items: { type: "number" }, - }); + test.each([ + "typescript", + "flow", + ] as const)("%s emits a top-level array alias", async (lang) => { + const output = await renderSchema(lang, "Values", { + type: "array", + items: { type: "number" }, + }); - expect(output).toContain("export type Values = number[];"); - }, - ); + expect(output).toContain("export type Values = number[];"); + }); - test.each(["typescript", "flow"] as const)( - "%s does not alias a map whose value type claims the top-level name", - async (lang) => { - const output = await renderSchema(lang, "TopLevel", { + test.each([ + "typescript", + "flow", + ] as const)("%s does not alias a map whose value type claims the top-level name", async (lang) => { + const output = await renderSchema(lang, "TopLevel", { + type: "object", + additionalProperties: { type: "object", - additionalProperties: { - type: "object", - properties: { - one: { type: "integer" }, - two: { type: "boolean" }, - }, - required: ["one", "two"], + properties: { + one: { type: "integer" }, + two: { type: "boolean" }, }, - }); - const declarations = - output.match(/export (?:type|interface) TopLevel\b/g) ?? []; + required: ["one", "two"], + }, + }); + const declarations = + output.match(/export (?:type|interface) TopLevel\b/g) ?? []; - expect(declarations).toHaveLength(1); - }, - ); + expect(declarations).toHaveLength(1); + }); }); From c0f4ed110649aaa908527037e3c9507a5c319f16 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:50:39 -0400 Subject: [PATCH 4/5] fix CI: skip empty-object.schema for Elixir (#undefined) Elixir's top-level from_json/to_json facade for non-array, non-class types just does Jason.decode!/Jason.encode! with no validation, so it can never reject a wrong-shaped .fail.json input. The new empty-object.schema fixture (a bare top-level map) hits exactly this case; skip it for Elixir the same way it's already skipped for JavaScriptPropTypes, Flow, PHP, TypeScriptZod, and TypeScriptEffectSchema. Co-Authored-By: gpt-5.6-sol via pi --- test/languages.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 70be76a828..3d45746165 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -2158,6 +2158,9 @@ export const ElixirLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + // The renderer does not support a bare top-level map. + "empty-object.schema", + // The error occurs because a guard clause that references TopLevel is compiled before TopLevel itself. To fix this, put // TopLevel before Bar, but this doesn't address the actual problem if for example a pattern match to Bar was in TopLevel. "mutually-recursive.schema", From 5db54d5bed878e3837bf0a5a78f964e699443a00 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 21 Jul 2026 11:47:47 -0400 Subject: [PATCH 5/5] test: skip empty-object.schema for Haskell The Haskell fixture driver encodes the Maybe result of decodeTopLevel, so a failed decode prints "null" and exits 0 rather than signalling an error. The empty-object.schema fail sample ([1,2,3] against a top-level HashMap) therefore cannot be detected as an expected failure, exactly like the already-skipped boolean-subschema.schema. Skip it for Haskell under the same rationale. Co-Authored-By: Claude --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index 94eed17cc7..ffca529e44 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1895,6 +1895,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. "boolean-subschema.schema", + "empty-object.schema", "nested-intersection-union.schema", "prefix-items.schema", "direct-union.schema",