From 301de7078a3ee230131531e67582daa7c5d93f47 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Tue, 4 Aug 2026 16:48:22 +0200 Subject: [PATCH] feat(typescript-zod): add prefer-const-values --- .../TypeScriptZod/TypeScriptZodRenderer.ts | 37 +++++++++++++++---- .../src/language/TypeScriptZod/language.ts | 9 ++++- .../schema/single-value-enum.1.fail.enum.json | 3 ++ test/inputs/schema/single-value-enum.1.json | 3 ++ test/inputs/schema/single-value-enum.schema | 10 +++++ test/languages.ts | 4 +- test/unit/typescript-zod-const-enum.test.ts | 36 ++++++++++++++++++ 7 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 test/inputs/schema/single-value-enum.1.fail.enum.json create mode 100644 test/inputs/schema/single-value-enum.1.json create mode 100644 test/inputs/schema/single-value-enum.schema create mode 100644 test/unit/typescript-zod-const-enum.test.ts diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index d9d62c617c..961d937071 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -33,6 +33,14 @@ import { legalizeName } from "../JavaScript/utils.js"; import type { typeScriptZodOptions } from "./language.js"; +type TypeScriptZodRendererOptions = Omit< + OptionValues, + "preferConstValues" +> & + Partial< + Pick, "preferConstValues"> + >; + export class TypeScriptZodRenderer extends ConvenienceRenderer { /** TypeRefs of object types that participate in a reference cycle. * These must be emitted as z.lazy() schemas with an explicit type @@ -42,7 +50,7 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { public constructor( targetLanguage: TargetLanguage, renderContext: RenderContext, - protected readonly _options: OptionValues, + protected readonly _options: TypeScriptZodRendererOptions, ) { super(targetLanguage, renderContext); } @@ -315,13 +323,26 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { protected emitEnum(e: EnumType, enumName: Name): void { this.ensureBlankLine(); this.emitDescription(this.descriptionForType(e)); - this.emitLine("\nexport const ", enumName, "Schema = ", "z.enum(["); - this.indent(() => - this.forEachEnumCase(e, "none", (_, jsonName) => { - this.emitLine('"', stringEscape(jsonName), '",'); - }), - ); - this.emitLine("]);"); + + if (this._options.preferConstValues && e.cases.size === 1) { + const value = e.cases.values().next().value; + if (value === undefined) panic("Single-value enum has no case."); + this.emitLine( + "\nexport const ", + enumName, + "Schema = z.literal(", + JSON.stringify(value), + ");", + ); + } else { + this.emitLine("\nexport const ", enumName, "Schema = ", "z.enum(["); + this.indent(() => + this.forEachEnumCase(e, "none", (_, jsonName) => { + this.emitLine('"', stringEscape(jsonName), '",'); + }), + ); + this.emitLine("]);"); + } if (!this._options.justSchema) { this.emitLine( "export type ", diff --git a/packages/quicktype-core/src/language/TypeScriptZod/language.ts b/packages/quicktype-core/src/language/TypeScriptZod/language.ts index 63b9abab51..e0fda8444f 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/language.ts @@ -16,6 +16,11 @@ import { TypeScriptZodRenderer } from "./TypeScriptZodRenderer.js"; export const typeScriptZodOptions = { justSchema: new BooleanOption("just-schema", "Schema only", false), + preferConstValues: new BooleanOption( + "prefer-const-values", + "Use literal schema for string enums with single value", + false, + ), }; export const typeScriptZodLanguageConfig = { @@ -35,8 +40,8 @@ export class TypeScriptZodTargetLanguage extends TargetLanguage< super(typeScriptZodLanguageConfig); } - public getOptions(): Record { - return {}; + public getOptions(): typeof typeScriptZodOptions { + return typeScriptZodOptions; } public get stringTypeMapping(): StringTypeMapping { diff --git a/test/inputs/schema/single-value-enum.1.fail.enum.json b/test/inputs/schema/single-value-enum.1.fail.enum.json new file mode 100644 index 0000000000..a9805423f7 --- /dev/null +++ b/test/inputs/schema/single-value-enum.1.fail.enum.json @@ -0,0 +1,3 @@ +{ + "kind": "other" +} diff --git a/test/inputs/schema/single-value-enum.1.json b/test/inputs/schema/single-value-enum.1.json new file mode 100644 index 0000000000..a2898d7092 --- /dev/null +++ b/test/inputs/schema/single-value-enum.1.json @@ -0,0 +1,3 @@ +{ + "kind": "only" +} diff --git a/test/inputs/schema/single-value-enum.schema b/test/inputs/schema/single-value-enum.schema new file mode 100644 index 0000000000..5fdf6fa04e --- /dev/null +++ b/test/inputs/schema/single-value-enum.schema @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["only"] + } + }, + "required": ["kind"] +} diff --git a/test/languages.ts b/test/languages.ts index 5e721fad1e..d47a4f6491 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -2114,7 +2114,9 @@ export const TypeScriptZodLanguage: Language = { "required-non-properties.schema", ], rendererOptions: {}, - quickTestRendererOptions: [], + quickTestRendererOptions: [ + ["single-value-enum.schema", { "prefer-const-values": "true" }], + ], sourceFiles: ["src/language/TypeScriptZod/index.ts"], }; diff --git a/test/unit/typescript-zod-const-enum.test.ts b/test/unit/typescript-zod-const-enum.test.ts new file mode 100644 index 0000000000..dd1b38672b --- /dev/null +++ b/test/unit/typescript-zod-const-enum.test.ts @@ -0,0 +1,36 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { expect, test } from "vitest"; + +async function render(preferConstValues: boolean): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify({ + type: "object", + properties: { + kind: { type: "string", enum: ["only"] }, + }, + required: ["kind"], + }), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript-zod", + rendererOptions: { "prefer-const-values": preferConstValues }, + }); + return result.lines.join("\n"); +} + +test("TypeScript Zod emits a literal for a single-value enum when preferred", async () => { + const output = await render(true); + + expect(output).toContain('z.literal("only")'); + expect(output).not.toContain("z.enum(["); +}); + +test("TypeScript Zod emits an enum for a single-value enum by default", async () => { + expect(await render(false)).toContain("z.enum(["); +});