From 9322b964401e83bf896d4506a1131dfc8897ca7b Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Wed, 7 Oct 2026 16:02:49 -0700 Subject: [PATCH] fix(zod): parenthesize optional and multi-value literal types in schema text z.array(z.literal(['a', 'b'])) was emitted as "a" | "b"[], which TypeScript reads as "a" | ("b"[]). Optional types and multi-value literals are emitted as unions, so they now get union precedence and are parenthesized in array, intersection and tuple positions. --- typescript/src/zod/validate.ts | 3 +++ typescript/test/zod.test.ts | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/typescript/src/zod/validate.ts b/typescript/src/zod/validate.ts index 02e5bb98..429a130b 100644 --- a/typescript/src/zod/validate.ts +++ b/typescript/src/zod/validate.ts @@ -60,8 +60,11 @@ const enum TypePrecedence { function getTypePrecedence(type: z.ZodType): TypePrecedence { switch (getTypeKind(type)) { case "enum": + case "optional": // emitted as `T | undefined` case "union": // covers both z.union() and z.discriminatedUnion() — Zod v4 merged discriminated unions into the regular union type kind ("ZodDiscriminatedUnion" in v3) return TypePrecedence.Union; + case "literal": // a multi-value literal is emitted as a union of its values + return (type._zod.def as z.core.$ZodLiteralDef).values.length > 1 ? TypePrecedence.Union : TypePrecedence.Object; case "intersection": return TypePrecedence.Intersection; } diff --git a/typescript/test/zod.test.ts b/typescript/test/zod.test.ts index b1ac5105..8fe3c85c 100644 --- a/typescript/test/zod.test.ts +++ b/typescript/test/zod.test.ts @@ -64,6 +64,18 @@ describe("getZodSchemaAsTypeScript", () => { assert.match(schemaOf("T", z.array(z.array(z.number()))), /type T = number\[\]\[\];/); }); + it("parenthesizes a multi-value literal element type", () => { + assert.match(schemaOf("T", z.array(z.literal(["a", "b"]))), /type T = \("a" \| "b"\)\[\];/); + }); + + it("parenthesizes an optional element type", () => { + assert.match(schemaOf("T", z.array(z.string().optional())), /type T = \(string \| undefined\)\[\];/); + }); + + it("does not parenthesize a single-value literal element type", () => { + assert.match(schemaOf("T", z.array(z.literal("a"))), /type T = "a"\[\];/); + }); + }); // ----------------------------------------------------------------------- @@ -124,6 +136,11 @@ describe("getZodSchemaAsTypeScript", () => { assert.match(out, /string \| number \| boolean/); }); + it("does not parenthesize optional or multi-value literal members", () => { + const out = schemaOf("T", z.union([z.string().optional(), z.literal(["a", "b"])])); + assert.match(out, /type T = string \| undefined \| "a" \| "b";/); + }); + }); describe("z.discriminatedUnion()", () => { @@ -153,6 +170,11 @@ describe("getZodSchemaAsTypeScript", () => { assert.match(out, /\{[^}]*a: string[^}]*\} & \{[^}]*b: number[^}]*\}/s); }); + it("parenthesizes optional and multi-value literal operands", () => { + const out = schemaOf("T", z.intersection(z.string().optional(), z.literal(["a", "b"]))); + assert.match(out, /type T = \(string \| undefined\) & \("a" \| "b"\);/); + }); + }); // ----------------------------------------------------------------------- @@ -179,6 +201,21 @@ describe("getZodSchemaAsTypeScript", () => { assert.match(out, /\[string, number\?\]/); }); + it("parenthesizes an optional rest element type", () => { + const out = schemaOf("T", z.tuple([z.string()]).rest(z.number().optional())); + assert.match(out, /\[string, \.\.\.\(number \| undefined\)\[\]\]/); + }); + + it("parenthesizes a multi-value literal rest element type", () => { + const out = schemaOf("T", z.tuple([z.string()]).rest(z.literal(["a", "b"]))); + assert.match(out, /\[string, \.\.\.\("a" \| "b"\)\[\]\]/); + }); + + it("parenthesizes a multi-value literal optional element", () => { + const out = schemaOf("T", z.tuple([z.string(), z.literal(["a", "b"]).optional()])); + assert.match(out, /\[string, \("a" \| "b"\)\?\]/); + }); + }); // -----------------------------------------------------------------------