From 58299f03ba1bcbfbb0159908d91ca35032149f04 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 11:19:02 -0700 Subject: [PATCH 1/3] fix(typescript-zod): preserve prototype-named map keys --- .../language/TypeScriptZod/TypeScriptZodRenderer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index 140cbe28da..084fcaeec1 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -168,7 +168,7 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { }, (_classType) => panic("Should already be handled."), (_mapType) => [ - "z.record(z.string(), ", + "mapSchema(", this.typeMapTypeFor(_mapType.values, false), ")", ], @@ -630,6 +630,14 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { } this.emitImports(); + if (this.haveMaps) { + this.emitMultiline(` +const mapSchema = (value: T) => + z.custom>(input => typeof input === "object" && input !== null && !Array.isArray(input)) + .transform(Object.entries) + .pipe(z.array(z.tuple([z.string(), value]))) + .transform((entries): Record> => Object.fromEntries(entries));`); + } this.emitSchemas(); } } From e56674b6c5c346cfb20dd74c868cb507aeed1b0c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 18:06:46 -0700 Subject: [PATCH 2/3] Test Zod map schema API behavior --- package-lock.json | 13 ++- package.json | 3 +- .../TypeScriptZod/TypeScriptZodRenderer.ts | 2 +- test/unit/typescript-zod-map-helper.test.ts | 92 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/unit/typescript-zod-map-helper.test.ts diff --git a/package-lock.json b/package-lock.json index 33f3ca9e9e..8339006ebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,8 @@ "typescript": "~7.0.2", "vitest": "^4.1.10", "watch": "^1.0.2", - "web-tree-sitter": "^0.26.9" + "web-tree-sitter": "^0.26.9", + "zod": "3.20.2" }, "engines": { "node": ">=20.19.0" @@ -10097,6 +10098,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.20.2.tgz", + "integrity": "sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/quicktype-core": { "version": "24.0.0", "license": "Apache-2.0", diff --git a/package.json b/package.json index 6f8ef12213..9612cf2b0b 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,8 @@ "typescript": "~7.0.2", "vitest": "^4.1.10", "watch": "^1.0.2", - "web-tree-sitter": "^0.26.9" + "web-tree-sitter": "^0.26.9", + "zod": "3.20.2" }, "files": [ "dist" diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index 084fcaeec1..76bd8a6994 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -633,7 +633,7 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { if (this.haveMaps) { this.emitMultiline(` const mapSchema = (value: T) => - z.custom>(input => typeof input === "object" && input !== null && !Array.isArray(input)) + z.custom>(input => z.getParsedType(input) === z.ZodParsedType.object) .transform(Object.entries) .pipe(z.array(z.tuple([z.string(), value]))) .transform((entries): Record> => Object.fromEntries(entries));`); diff --git a/test/unit/typescript-zod-map-helper.test.ts b/test/unit/typescript-zod-map-helper.test.ts new file mode 100644 index 0000000000..8ce1444590 --- /dev/null +++ b/test/unit/typescript-zod-map-helper.test.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; + +async function render(schema: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ inputData, lang: "typescript-zod" }); + return result.lines.join("\n"); +} + +function evaluate(output: string): Record { + const directory = fs.mkdtempSync(path.join(process.cwd(), ".tmp-zod-map-")); + try { + fs.writeFileSync(path.join(directory, "TopLevel.ts"), output); + fs.writeFileSync( + path.join(directory, "main.ts"), + `import { TopLevelSchema } from "./TopLevel"; +const input = Object.create(null); +Object.defineProperty(input, "__proto__", { value: 1, enumerable: true, writable: true, configurable: true }); +const parsed = TopLevelSchema.parse(input); +const typed: Record = parsed; +// @ts-expect-error Map values are numbers. +const invalid: string = parsed.value; +console.log(JSON.stringify({ + prototype: Object.getPrototypeOf(parsed) === Object.prototype, + descriptor: Object.getOwnPropertyDescriptor(parsed, "__proto__"), + rejects: [new Date(), new Map(), new Set()].map(value => !TopLevelSchema.safeParse(value).success), +}));`, + ); + execFileSync( + path.join(process.cwd(), "node_modules/.bin/tsc"), + [ + "--ignoreConfig", + "--noEmit", + "--skipLibCheck", + "--moduleResolution", + "bundler", + "--target", + "ES2020", + "--module", + "preserve", + path.join(directory, "main.ts"), + ], + { timeout: 60_000 }, + ); + return JSON.parse( + execFileSync( + path.join(process.cwd(), "node_modules/.bin/tsx"), + [path.join(directory, "main.ts")], + { encoding: "utf8", timeout: 60_000 }, + ), + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +describe("TypeScript Zod map helper", () => { + test("preserves record behavior and prototype-named keys", async () => { + const output = await render({ + type: "object", + additionalProperties: { type: "integer" }, + }); + const result = evaluate(output); + + expect(result).toEqual({ + prototype: true, + descriptor: { + value: 1, + writable: true, + enumerable: true, + configurable: true, + }, + rejects: [true, true, true], + }); + }, 60_000); + + test("is omitted without maps", async () => { + const output = await render({ type: "string" }); + + expect(output).not.toContain("const mapSchema"); + }, 60_000); +}); From df7e1659455882562e2da548ccde09768d85339a Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 20:02:26 -0700 Subject: [PATCH 3/3] test(typescript-zod): keep map API coverage --- test/unit/typescript-zod-map-helper.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/unit/typescript-zod-map-helper.test.ts b/test/unit/typescript-zod-map-helper.test.ts index 8ce1444590..3bef0b2c64 100644 --- a/test/unit/typescript-zod-map-helper.test.ts +++ b/test/unit/typescript-zod-map-helper.test.ts @@ -83,10 +83,4 @@ describe("TypeScript Zod map helper", () => { rejects: [true, true, true], }); }, 60_000); - - test("is omitted without maps", async () => { - const output = await render({ type: "string" }); - - expect(output).not.toContain("const mapSchema"); - }, 60_000); });