From e6514f9c61eb5e4c228165400b65cf0d32e16351 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:06:58 -0400 Subject: [PATCH 1/5] fix(typescript): emit top-level alias for bare map/array schemas (#2642) 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 | 6 ++ test/languages.ts | 16 +++- .../typescript-flow-top-level-alias.test.ts | 83 +++++++++++++++++++ 5 files changed, 118 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 d833317d86..0ba1b816d8 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, @@ -178,15 +182,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..f5c0db9101 --- /dev/null +++ b/test/inputs/schema/empty-object.schema @@ -0,0 +1,6 @@ +{ + "id": "http://json-schema.org/geo", + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": {} +} diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..28bb671f4a 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1049,7 +1049,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" }], @@ -1247,7 +1250,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: [], @@ -1812,6 +1818,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). @@ -1924,6 +1932,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", @@ -2045,6 +2055,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..782a0291db --- /dev/null +++ b/test/unit/typescript-flow-top-level-alias.test.ts @@ -0,0 +1,83 @@ +// A top-level empty-object schema is inferred as a map. The schema fixture +// exercises its generated converters, but those converters inline the map +// 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"); +} + +const emptyObjectSchema = { + $schema: "http://json-schema.org/draft-06/schema#", + type: "object", + properties: {}, +}; + +describe("TypeScript/Flow unnamed top-level aliases", () => { + test.each([ + ["typescript", "unknown"], + ["flow", "mixed"], + ] as const)("%s emits an empty-object map alias", async (lang, anyType) => { + const output = await renderSchema( + lang, + "EmptySchema", + emptyObjectSchema, + ); + + expect(output).toContain( + `export type EmptySchema = { [key: string]: ${anyType} };`, + ); + }); + + test("TypeScript emits a top-level array alias", async () => { + const output = await renderSchema("typescript", "Names", { + type: "array", + items: { type: "string" }, + }); + + expect(output).toContain("export type Names = string[];"); + }); + + 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 c3e449fd6a0bae8b6875ed02b51f5dffcf8ac996 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 19:46:06 -0400 Subject: [PATCH 2/5] test: add missing fixture cases for empty-object.schema (#2991) 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 dba7cf4f0fd76eaf39432b69b30558eee0d65194 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:30:06 -0400 Subject: [PATCH 3/5] test: fix gating of empty-object fixture cases (#2991) The bare empty-object.1.fail.json (a non-object array) runs for every language, but the Elixir emitter renders a bare top-level map as a Jason.decode!/encode! pass-through with no shape validation, so it accepts the array and round-trips it instead of exiting nonzero. This is the same permissiveness class as go-schema-pattern-properties, which Elixir already skips. Skip empty-object.schema for Elixir; the positive and negative cases still run for all other schema languages (verified locally with schema-javascript and schema-golang). Co-Authored-By: Claude --- test/languages.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 28bb671f4a..a16cac2d06 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -2130,6 +2130,12 @@ export const ElixirLanguage: Language = { // for the Elixir emitter could be a user-controlled 'strict' mode that pattern matches even on unions of only primitive types. "go-schema-pattern-properties.schema", + // A bare top-level map is emitted as a Jason.decode!/encode! pass-through + // with no shape validation, so the .fail.json case (a non-object) is not + // rejected and round-trips instead of exiting nonzero. Same permissiveness + // class as go-schema-pattern-properties above. + "empty-object.schema", + // The generated top-level type is not emitted as a TopLevel module the fixture can call. "recursive-union-flattening.schema", ], From 677367b846470cb93dfc625e7f083a815918b0b2 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 21:24:56 -0400 Subject: [PATCH 4/5] fix CI: skip empty-object.schema for Haskell's Maybe-decode limitation (#2991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Haskell fixture driver encodes a failed decode's Maybe result as the JSON literal null and exits 0, so expected-failure schema samples can never be detected — already documented and worked around for nested-intersection-union.schema, prefix-items.schema, and direct-union.schema. The new empty-object.schema fixture (added earlier on this branch) hits the same limitation; add it to the same skip group. Co-Authored-By: gpt-5.6-sol via pi --- test/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/languages.ts b/test/languages.ts index a16cac2d06..fc399f7637 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1769,6 +1769,7 @@ export const HaskellLanguage: Language = { "nested-intersection-union.schema", "prefix-items.schema", "direct-union.schema", + "empty-object.schema", ...skipsEnumValueValidation, "go-schema-pattern-properties.schema", "intersection.schema", From 086877f075e1352656e0bdbc40632c6ba428bfbe Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Thu, 23 Jul 2026 16:37:19 -0400 Subject: [PATCH 5/5] refactor(typescript): reuse top-level naming decision --- .../src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index bb75cfbd94..09c06ee80a 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -245,7 +245,7 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { this.ensureBlankLine(); this.emitDescription(this.descriptionForType(t)); this.emitLine( - "export type ", + t.isPrimitive() ? "type " : "export type ", name, " = ", this.sourceFor(t).source,