From e45e61b675baf055f4a3ef2ddead21715a1e4fe3 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Sun, 24 Aug 2025 00:09:50 -0700 Subject: [PATCH 01/15] Improve codec docs --- packages/docs/content/codecs.mdx | 54 +++++++++++--------------------- play.ts | 12 +++++++ 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index 8ae42a9b4b..05d300bc5d 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -7,41 +7,28 @@ import { ThemedImage } from "@/components/themed-image"; > ✨ **New** — Introduced in `zod@4.1` -> **TLDR** — You can use `z.encode(, )` to perform an _encode_ operation ("reverse parsing"). This will work with any schema (except those containing `.transform()`) but is primarily intended for use in conjunction with `z.codec()`. +All Zod schemas can process inputs in both the forward and backward direction: -Zod schemas are "codecs". That is, they can process inputs in both the forward and backward direction: +- **Forward**: `Input` to `Output` + - `.parse()` + - `.decode()` +- **Backward**: `Output` to `Input` + - `.encode()` -- *Decoding*: the "forward" direction: `Input -> Output`. This is what regular `.parse()` does. -- *Encoding*: the "backward" direction: `Output -> Input`. - -The regular `.parse()` method performs a *decode* operation (forward direction). +In most cases, this is a distinction without a difference. The input and output types are identical, so there's no difference between "forward" and "backward". ```ts -import * as z from "zod"; - -const mySchema = z.string(); +const schema = z.string(); -// method form -mySchema.parse("hello"); // => "hello" +type Input = z.input; // string +type Output = z.output; // string -// functional form -z.parse(mySchema, "hello"); // => "hello" +schema.parse("asdf"); // => "asdf" +schema.decode("asdf"); // => "asdf" +schema.encode("asdf"); // => "asdf" ``` -For explicitness, Zod provides dedicated functions for performing "decode" and "encode" operations. - -```ts -z.decode(mySchema, "hello"); // => "hello" -z.encode(mySchema, "hello"); // => "hello" -``` - -In many cases (such as the string schema above), the input and output types of a Zod schema are identical, so `z.decode()` and `z.encode()` are functionally equivalent. But some schema types cause the input and output types to diverge: - -- `z.default()` (input is optional, output is not) -- `z.transform()` (a unidirectional transformation) -- `z.codec()` (bidirectional transformation) - -Most important of these is `z.codec()`, which is Zod's primary mechanism for defining bidirectional transformations. +However, some schema types cause the input and output types to diverge, notably `z.codec()`. Codecs are a special type of schema that defines a *bi-directional transformation* between two other schemas. ```ts const stringToDate = z.codec( @@ -52,9 +39,6 @@ const stringToDate = z.codec( encode: (date) => date.toISOString(), // Date → ISO string } ); - -type Input = z.input; // => string -type Output = z.output; // => Date ``` In these cases, `z.decode()` and `z.encode()` behave quite differently. @@ -62,10 +46,10 @@ In these cases, `z.decode()` and `z.encode()` behave quite differently. ```ts const payloadSchema = z.object({ startDate: stringToDate }); -z.decode(stringToDate, "2024-01-15T10:30:00.000Z") +stringToDate.decode("2024-01-15T10:30:00.000Z") // => Date -z.encode(stringToDate, new Date("2024-01-15T10:30:00.000Z")) +stringToDate.encode(new Date("2024-01-15T10:30:00.000Z")) // => string ``` @@ -79,14 +63,14 @@ This is particularly useful when parsing data at a network boundary. You can sha alt="Codecs encoding and decoding data across a network boundary" /> -## Composability +### Composability > **Note** — You can use `z.encode()` and `z.decode()` with any schema. It doesn't have to be a ZodCodec. Codecs are a schema like any other. You can nest them inside objects, arrays, pipes, etc. There are no rules on where you can use them! -## Type-safe inputs +### Type-safe inputs The usual `.parse()` method accepts `unknown` as input, and returns a value that matches the schema's inferred *output type*. @@ -110,7 +94,7 @@ Here's a diagram demonstrating the differences between the type signatures for ` alt="Codec directionality diagram showing bidirectional transformation between input and output schemas" /> -## Async and safe variants +### Async and safe variants As with `.transform()` and `.refine()`, codecs support async transforms. diff --git a/play.ts b/play.ts index 4564a8e840..dc872eced9 100644 --- a/play.ts +++ b/play.ts @@ -1,3 +1,15 @@ import * as z from "zod"; z; + +const stringToDate = z.codec( + z.iso.datetime(), // input schema: ISO string + z.date(), // output schema: Date object + { + decode: (isoString) => new Date(isoString), // string → Date + encode: (date) => date.toISOString(), // Date → string + } +); + +console.log(stringToDate.decode("2024-01-15T10:30:00.000Z")); // Date +console.log(stringToDate.encode(new Date("2024-01-15"))); // "2024-01-15T00:00:00.000Z" From 25a4c376834db90d3bb3e70d4154e3eb6d4feb7a Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Sun, 24 Aug 2025 22:47:59 +0200 Subject: [PATCH 02/15] fix(v4): toJSONSchema - wrong record tuple output when targeting `openapi-3.0` (#5145) * fix(v4): toJSONSchema - wrong record tuple output when targeting `openapi-3.0` * chore: rename unsued variable in get-llm-text.ts --- packages/docs/loaders/get-llm-text.ts | 2 +- .../v4/classic/tests/to-json-schema.test.ts | 58 +++++++++++++++++++ packages/zod/src/v4/core/to-json-schema.ts | 38 ++++++------ 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/packages/docs/loaders/get-llm-text.ts b/packages/docs/loaders/get-llm-text.ts index 2b44bbc6b2..27d7ff30e9 100644 --- a/packages/docs/loaders/get-llm-text.ts +++ b/packages/docs/loaders/get-llm-text.ts @@ -13,7 +13,7 @@ const processor = remark().use(remarkMdx).use(remarkInclude).use(remarkGfm).use( export async function getLLMText(page: InferPageType): Promise { const filePath = page.data._file.absolutePath; const fileContent = await fs.readFile(filePath); - const { content, data } = matter(fileContent.toString()); + const { content } = matter(fileContent.toString()); const processed = await processor.process({ path: filePath, diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index 1e242235b1..c0a206b2f1 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -653,6 +653,24 @@ describe("toJSONSchema", () => { }); test("tuple", () => { + const schema = z.tuple([z.string(), z.number()]); + expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + { + "type": "string", + }, + { + "type": "number", + }, + ], + "type": "array", + } + `); + }); + + test("tuple with rest", () => { const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { @@ -673,6 +691,46 @@ describe("toJSONSchema", () => { `); }); + test("tuple openapi", () => { + const schema = z.tuple([z.string(), z.number()]); + expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(` + { + "items": [ + { + "type": "string", + }, + { + "type": "number", + }, + ], + "maxItems": 2, + "minItems": 2, + "type": "array", + } + `); + }); + + test("tuple with rest openapi", () => { + const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); + expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(` + { + "items": [ + { + "type": "string", + }, + { + "type": "number", + }, + { + "type": "boolean", + }, + ], + "minItems": 2, + "type": "array", + } + `); + }); + test("promise", () => { const schema = z.promise(z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index 315e15faf6..a18b2fcdc6 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -371,32 +371,34 @@ export class JSONSchemaGenerator { const prefixItems = def.items.map((x, i) => this.process(x, { ...params, path: [...params.path, "prefixItems", i] }) ); + const rest = def.rest + ? this.process(def.rest, { + ...params, + path: [...params.path, "items"], + }) + : null; + if (this.target === "draft-2020-12") { json.prefixItems = prefixItems; + if (rest) { + json.items = rest; + } + } else if (this.target === "openapi-3.0") { + json.items = [...prefixItems]; + if (rest) { + json.items.push(rest); + } + json.minItems = prefixItems.length; + if (!rest) { + json.maxItems = prefixItems.length; + } } else { json.items = prefixItems; - } - - if (def.rest) { - const rest = this.process(def.rest, { - ...params, - path: [...params.path, "items"], - }); - if (this.target === "draft-2020-12") { - json.items = rest; - } else { + if (rest) { json.additionalItems = rest; } } - // additionalItems - if (def.rest) { - json.items = this.process(def.rest, { - ...params, - path: [...params.path, "items"], - }); - } - // length const { minimum, maximum } = schema._zod.bag as { minimum?: number; From 0fa4f464e0e679d71b183e8811ef1e4f30aaa06a Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 25 Aug 2025 15:18:54 -0700 Subject: [PATCH 03/15] Use method form in codecs.mdx --- packages/docs/content/codecs.mdx | 160 +++++++++++++++++++------------ play.ts | 12 --- 2 files changed, 97 insertions(+), 75 deletions(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index 05d300bc5d..788710163e 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -3,6 +3,7 @@ title: Codecs description: "Bidirectional transformations with encode and decode" --- +import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; import { ThemedImage } from "@/components/themed-image"; > ✨ **New** — Introduced in `zod@4.1` @@ -17,6 +18,8 @@ All Zod schemas can process inputs in both the forward and backward direction: In most cases, this is a distinction without a difference. The input and output types are identical, so there's no difference between "forward" and "backward". + + ```ts const schema = z.string(); @@ -27,6 +30,21 @@ schema.parse("asdf"); // => "asdf" schema.decode("asdf"); // => "asdf" schema.encode("asdf"); // => "asdf" ``` + + +```ts +const schema = z.string(); + +type Input = z.input; // string +type Output = z.output; // string + +z.parse(schema, "asdf"); // => "asdf" +z.decode(schema, "asdf"); // => "asdf" +z.encode(schema, "asdf"); // => "asdf" +``` + + + However, some schema types cause the input and output types to diverge, notably `z.codec()`. Codecs are a special type of schema that defines a *bi-directional transformation* between two other schemas. @@ -43,6 +61,8 @@ const stringToDate = z.codec( In these cases, `z.decode()` and `z.encode()` behave quite differently. + + ```ts const payloadSchema = z.object({ startDate: stringToDate }); @@ -52,6 +72,19 @@ stringToDate.decode("2024-01-15T10:30:00.000Z") stringToDate.encode(new Date("2024-01-15T10:30:00.000Z")) // => string ``` + + +```ts +const payloadSchema = z.object({ startDate: stringToDate }); + +z.decode(stringToDate, "2024-01-15T10:30:00.000Z") +// => Date + +z.encode(stringToDate, new Date("2024-01-15T10:30:00.000Z")) +// => string +``` + + > **Note** —There's nothing special about the directions or terminology here. Instead of *encoding* with an `A -> B` codec, you could instead *decode* with a `B -> A` codec. The use of the terms "decode" and "encode" is just a convention. @@ -78,14 +111,15 @@ By constrast, the `z.decode()` and `z.encode()` functions have *strongly-typed i ```ts stringToDate.parse(12345); -// no complaints from TypeScript (but it will fail at runtime) +// no complaints from TypeScript (fails at runtime) -z.decode(stringToDate, 12345); +stringToDate.decode(12345); // ❌ TypeScript error: Argument of type 'number' is not assignable to parameter of type 'string'. -z.encode(stringToDate, 12345); +stringToDate.encode(12345); // ❌ TypeScript error: Argument of type 'number' is not assignable to parameter of type 'Date'. ``` + Here's a diagram demonstrating the differences between the type signatures for `parse()`, `decode()`, and `encode()`. Date -z.decodeAsync(stringToDate, "2024-01-15T10:30:00.000Z"); +stringToDate.decodeAsync("2024-01-15T10:30:00.000Z"); // => Promise -z.decodeSafe(stringToDate, "2024-01-15T10:30:00.000Z"); +stringToDate.decodeSafe("2024-01-15T10:30:00.000Z"); // => { success: true, data: Date } | { success: false, error: ZodError } -z.decodeSafeAsync(stringToDate, "2024-01-15T10:30:00.000Z"); +stringToDate.decodeSafeAsync("2024-01-15T10:30:00.000Z"); // => Promise<{ success: true, data: Date } | { success: false, error: ZodError }> ``` @@ -139,10 +173,10 @@ const stringToDate = z.codec( } ); -z.decode(stringToDate, "2024-01-15T10:30:00.000Z"); +stringToDate.decode("2024-01-15T10:30:00.000Z"); // => Date -z.encode(stringToDate, new Date("2024-01-15")); +stringToDate.encode(new Date("2024-01-15")); // => string ``` @@ -160,10 +194,10 @@ All checks (`.refine()`, `.min()`, `.max()`, etc.) are still executed in both di ```ts const schema = stringToDate.refine((date) => date.getFullYear() > 2000, "Must be this millenium"); -z.encode(schema, new Date("2000-01-01")); +schema.encode(new Date("2000-01-01")); // => Date -z.encode(schema, new Date("1999-01-01")); +schema.encode(new Date("1999-01-01")); // => ❌ ZodError: [ // { // "code": "custom", @@ -180,10 +214,10 @@ This approach also supports "mutating transforms" like `z.string().trim()` or `z ```ts const schema = z.string().trim(); -z.decode(schema, " hello "); +schema.decode(" hello "); // => "hello" -z.encode(schema, " hello "); +schema.encode(" hello "); // => "hello" ``` @@ -193,10 +227,10 @@ Defaults and prefaults are only applied in the "forward" direction. ```ts const stringWithDefault = z.string().default("hello"); -z.decode(stringWithDefault, undefined); +stringWithDefault.decode(undefined); // => "hello" -z.encode(stringWithDefault, undefined); +stringWithDefault.encode(undefined); // => ZodError: Expected string, received undefined ``` @@ -209,10 +243,10 @@ Similarly, `.catch()` is only applied in the "forward" direction. ```ts const stringWithCatch = z.string().catch("hello"); -z.decode(stringWithCatch, 1234); +stringWithCatch.decode(1234); // => "hello" -z.encode(stringWithCatch, 1234); +stringWithCatch.encode(1234); // => ZodError: Expected string, received number ``` @@ -225,11 +259,11 @@ The `z.stringbool()` API converts string values (`"true"`, `"false"`, `"yes"`, ` ```ts const stringbool = z.stringbool(); -z.decode(stringbool, "true"); // => true -z.decode(stringbool, "false"); // => false +stringbool.decode("true"); // => true +stringbool.decode("false"); // => false -z.encode(stringbool, true); // => "true" -z.encode(stringbool, false); // => "false" +stringbool.encode(true); // => "true" +stringbool.encode(false); // => "false" ``` If you specify a custom set of `truthy` and `falsy` values, the *first element in the array* will be used instead. @@ -237,8 +271,8 @@ If you specify a custom set of `truthy` and `falsy` values, the *first element i ```ts const stringbool = z.stringbool({ truthy: ["yes", "y"], falsy: ["no", "n"] }); -z.encode(stringbool, true); // => "yes" -z.encode(stringbool, false); // => "no" +stringbool.encode(true); // => "yes" +stringbool.encode(false); // => "no" ``` ### Transforms @@ -248,7 +282,7 @@ z.encode(stringbool, false); // => "no" ```ts const schema = z.string().transform(val => val.length); -z.encode(schema, 1234); +schema.encode(1234); // ❌ Error: Encountered unidirectional transform during encode: ZodTransform ``` @@ -283,8 +317,8 @@ const stringToNumber = z.codec(z.string().regex(z.regexes.number), z.number(), { encode: (num) => num.toString(), }); -z.decode(stringToNumber, "42.5"); // => 42.5 -z.encode(stringToNumber, 42.5); // => "42.5" +stringToNumber.decode("42.5"); // => 42.5 +stringToNumber.encode(42.5); // => "42.5" ``` ### `stringToInt` @@ -297,8 +331,8 @@ const stringToInt = z.codec(z.string().regex(z.regexes.integer), z.int(), { encode: (num) => num.toString(), }); -z.decode(stringToInt, "42"); // => 42 -z.encode(stringToInt, 42); // => "42" +stringToInt.decode("42"); // => 42 +stringToInt.encode(42); // => "42" ``` ### `stringToBigInt` @@ -311,8 +345,8 @@ const stringToBigInt = z.codec(z.string(), z.bigint(), { encode: (bigint) => bigint.toString(), }); -z.decode(stringToBigInt, "123456789012345678901234567890"); // => 123456789012345678901234567890n -z.encode(stringToBigInt, 123456789012345678901234567890n); // => "123456789012345678901234567890" +stringToBigInt.decode("12345"); // => 12345n +stringToBigInt.encode(12345n); // => "12345" ``` ### `numberToBigInt` @@ -325,8 +359,8 @@ const numberToBigInt = z.codec(z.int(), z.bigint(), { encode: (bigint) => Number(bigint), }); -z.decode(numberToBigInt, 42); // => 42n -z.encode(numberToBigInt, 42n); // => 42 +numberToBigInt.decode(42); // => 42n +numberToBigInt.encode(42n); // => 42 ``` ### `isoDatetimeToDate` @@ -339,8 +373,8 @@ const isoDatetimeToDate = z.codec(z.iso.datetime(), z.date(), { encode: (date) => date.toISOString(), }); -z.decode(isoDatetimeToDate, "2024-01-15T10:30:00.000Z"); // => Date object -z.encode(isoDatetimeToDate, new Date("2024-01-15")); // => "2024-01-15T00:00:00.000Z" +isoDatetimeToDate.decode("2024-01-15T10:30:00.000Z"); // => Date object +isoDatetimeToDate.encode(new Date("2024-01-15")); // => "2024-01-15T00:00:00.000Z" ``` ### `epochSecondsToDate` @@ -353,8 +387,8 @@ const epochSecondsToDate = z.codec(z.int().min(0), z.date(), { encode: (date) => Math.floor(date.getTime() / 1000), }); -z.decode(epochSecondsToDate, 1705314600); // => Date object -z.encode(epochSecondsToDate, new Date()); // => Unix timestamp in seconds +epochSecondsToDate.decode(1705314600); // => Date object +epochSecondsToDate.encode(new Date()); // => Unix timestamp in seconds ``` ### `epochMillisToDate` @@ -367,8 +401,8 @@ const epochMillisToDate = z.codec(z.int().min(0), z.date(), { encode: (date) => date.getTime(), }); -z.decode(epochMillisToDate, 1705314600000); // => Date object -z.encode(epochMillisToDate, new Date()); // => Unix timestamp in milliseconds +epochMillisToDate.decode(1705314600000); // => Date object +epochMillisToDate.encode(new Date()); // => Unix timestamp in milliseconds ``` ### `jsonCodec` @@ -400,7 +434,7 @@ To further validate the resulting JSON data, pipe the result into another schema ```ts const jsonStringToData = jsonCodec.pipe(z.object({ name: z.string(), age: z.number() })); -z.decode(jsonStringToData, '~~invalid~~'); +jsonStringToData.decode('~~invalid~~'); // ZodError: [ // { // "code": "invalid_format", @@ -410,10 +444,10 @@ z.decode(jsonStringToData, '~~invalid~~'); // } // ] -z.decode(myCodec, '{"name":"Alice","age":30}'); +myCodec.decode('{"name":"Alice","age":30}'); // => { name: "Alice", age: 30 } -z.encode(myCodec, { name: "Bob", age: 25 }); +myCodec.encode({ name: "Bob", age: 25 }); // => '{"name":"Bob","age":25}' ``` @@ -427,8 +461,8 @@ const utf8ToBytes = z.codec(z.string(), z.instanceof(Uint8Array), { encode: (bytes) => new TextDecoder().decode(bytes), }); -z.decode(utf8ToBytes, "Hello, 世界!"); // => Uint8Array -z.encode(utf8ToBytes, bytes); // => "Hello, 世界!" +utf8ToBytes.decode("Hello, 世界!"); // => Uint8Array +utf8ToBytes.encode(bytes); // => "Hello, 世界!" ``` ### `bytesToUtf8` @@ -441,8 +475,8 @@ const bytesToUtf8 = z.codec(z.instanceof(Uint8Array), z.string(), { encode: (str) => new TextEncoder().encode(str), }); -z.decode(bytesToUtf8, bytes); // => "Hello, 世界!" -z.encode(bytesToUtf8, "Hello, 世界!"); // => Uint8Array +bytesToUtf8.decode(bytes); // => "Hello, 世界!" +bytesToUtf8.encode("Hello, 世界!"); // => Uint8Array ``` ### `base64ToBytes` @@ -455,8 +489,8 @@ const base64ToBytes = z.codec(z.base64(), z.instanceof(Uint8Array), { encode: (bytes) => z.core.util.uint8ArrayToBase64(bytes), }); -z.decode(base64ToBytes, "SGVsbG8="); // => Uint8Array([72, 101, 108, 108, 111]) -z.encode(base64ToBytes, bytes); // => "SGVsbG8=" +base64ToBytes.decode("SGVsbG8="); // => Uint8Array([72, 101, 108, 108, 111]) +base64ToBytes.encode(bytes); // => "SGVsbG8=" ``` ### `base64urlToBytes` @@ -469,8 +503,8 @@ const base64urlToBytes = z.codec(z.base64url(), z.instanceof(Uint8Array), { encode: (bytes) => z.core.util.uint8ArrayToBase64url(bytes), }); -z.decode(base64urlToBytes, "SGVsbG8"); // => Uint8Array([72, 101, 108, 108, 111]) -z.encode(base64urlToBytes, bytes); // => "SGVsbG8" +base64urlToBytes.decode("SGVsbG8"); // => Uint8Array([72, 101, 108, 108, 111]) +base64urlToBytes.encode(bytes); // => "SGVsbG8" ``` ### `hexToBytes` @@ -483,8 +517,8 @@ const hexToBytes = z.codec(z.hex(), z.instanceof(Uint8Array), { encode: (bytes) => z.core.util.uint8ArrayToHex(bytes), }); -z.decode(hexToBytes, "48656c6c6f"); // => Uint8Array([72, 101, 108, 108, 111]) -z.encode(hexToBytes, bytes); // => "48656c6c6f" +hexToBytes.decode("48656c6c6f"); // => Uint8Array([72, 101, 108, 108, 111]) +hexToBytes.encode(bytes); // => "48656c6c6f" ``` ### `stringToURL` @@ -497,8 +531,8 @@ const stringToURL = z.codec(z.url(), z.instanceof(URL), { encode: (url) => url.href, }); -z.decode(stringToURL, "https://example.com/path"); // => URL object -z.encode(stringToURL, new URL("https://example.com")); // => "https://example.com/" +stringToURL.decode("https://example.com/path"); // => URL object +stringToURL.encode(new URL("https://example.com")); // => "https://example.com/" ``` ### `stringToHttpURL` @@ -511,8 +545,8 @@ const stringToHttpURL = z.codec(z.httpUrl(), z.instanceof(URL), { encode: (url) => url.href, }); -z.decode(stringToHttpURL, "https://api.example.com/v1"); // => URL object -z.encode(stringToHttpURL, url); // => "https://api.example.com/v1" +stringToHttpURL.decode("https://api.example.com/v1"); // => URL object +stringToHttpURL.encode(url); // => "https://api.example.com/v1" ``` ### `uriComponent` @@ -525,8 +559,8 @@ const uriComponent = z.codec(z.string(), z.string(), { encode: (decodedString) => encodeURIComponent(decodedString), }); -z.decode(uriComponent, "Hello%20World%21"); // => "Hello World!" -z.encode(uriComponent, "Hello World!"); // => "Hello%20World!" +uriComponent.decode("Hello%20World%21"); // => "Hello World!" +uriComponent.encode("Hello World!"); // => "Hello%20World!" ``` ### `stringToBoolean()` @@ -538,13 +572,13 @@ const stringToBoolean = (options?: { truthy?: string[]; falsy?: string[] }) => z.stringbool(options); const codec = stringToBoolean(); -z.decode(codec, "true"); // => true -z.decode(codec, "false"); // => false -z.encode(codec, true); // => "true" -z.encode(codec, false); // => "false" +codec.decode("true"); // => true +codec.decode("false"); // => false +codec.encode(true); // => "true" +codec.encode(false); // => "false" // With custom options const customCodec = stringToBoolean({ truthy: ["yes", "y"], falsy: ["no", "n"] }); -z.decode(customCodec, "yes"); // => true -z.encode(customCodec, true); // => "yes" +customCodec.decode("yes"); // => true +customCodec.encode(true); // => "yes" ``` diff --git a/play.ts b/play.ts index dc872eced9..4564a8e840 100644 --- a/play.ts +++ b/play.ts @@ -1,15 +1,3 @@ import * as z from "zod"; z; - -const stringToDate = z.codec( - z.iso.datetime(), // input schema: ISO string - z.date(), // output schema: Date object - { - decode: (isoString) => new Date(isoString), // string → Date - encode: (date) => date.toISOString(), // Date → string - } -); - -console.log(stringToDate.decode("2024-01-15T10:30:00.000Z")); // Date -console.log(stringToDate.encode(new Date("2024-01-15"))); // "2024-01-15T00:00:00.000Z" From 940383d0523da41c4e2422b76455da39dddd6c4f Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 25 Aug 2025 16:02:48 -0700 Subject: [PATCH 04/15] Update JSON codec and docs --- packages/docs/content/codecs.mdx | 75 ++-- packages/zod/src/v4/classic/external.ts | 1 + .../v4/classic/tests/codec-examples.test.ts | 319 ++++++++++-------- packages/zod/src/v4/mini/external.ts | 1 + play.ts | 19 ++ 5 files changed, 235 insertions(+), 180 deletions(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index 788710163e..2477856745 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -105,9 +105,7 @@ Codecs are a schema like any other. You can nest them inside objects, arrays, pi ### Type-safe inputs -The usual `.parse()` method accepts `unknown` as input, and returns a value that matches the schema's inferred *output type*. - -By constrast, the `z.decode()` and `z.encode()` functions have *strongly-typed inputs*. +While `.parse()` and `.decode()` behave identically at *runtime*, they have different type signatures. The `.parse()` method accepts `unknown` as input, and returns a value that matches the schema's inferred *output type*. By constrast, the `z.decode()` and `z.encode()` functions have *strongly-typed inputs*. ```ts stringToDate.parse(12345); @@ -120,6 +118,7 @@ stringToDate.encode(12345); // ❌ TypeScript error: Argument of type 'number' is not assignable to parameter of type 'Date'. ``` +Why the difference? Encoding and decoding imply *transformation*. In many cases, the inputs to these methods Here's a diagram demonstrating the differences between the type signatures for `parse()`, `decode()`, and `encode()`. Date object epochMillisToDate.encode(new Date()); // => Unix timestamp in milliseconds ``` -### `jsonCodec` +### `json(schema)` -Parses JSON strings into structured data and serializes back to JSON. +Parses JSON strings into structured data and serializes back to JSON. This generic function accepts an output schema to validate the parsed JSON data. ```ts -// uses JSON.parse()/JSON.stringify() to transform between string and JSON -const jsonCodec = z.codec(z.string(), z.json(), { - decode: (jsonString, ctx) => { - try { - return JSON.parse(jsonString); - } catch (err: any) { - ctx.issues.push({ - code: "invalid_format", - format: "json_string", - input: jsonString, - message: err.message, - }); - return z.NEVER; - } - }, - encode: (value) => JSON.stringify(value), -}); +const jsonCodec = (schema: T) => + z.codec(z.string(), schema, { + decode: (jsonString, ctx) => { + try { + return JSON.parse(jsonString); + } catch (err: any) { + ctx.issues.push({ + code: "invalid_format", + format: "json", + input: jsonString, + message: err.message, + }); + return z.NEVER; + } + }, + encode: (value) => JSON.stringify(value), + }); ``` -To further validate the resulting JSON data, pipe the result into another schema. +Usage example with a specific schema: ```ts -const jsonStringToData = jsonCodec.pipe(z.object({ name: z.string(), age: z.number() })); +const jsonToObject = json(z.object({ name: z.string(), age: z.number() })); + +jsonToObject.decode('{"name":"Alice","age":30}'); +// => { name: "Alice", age: 30 } + +jsonToObject.encode({ name: "Bob", age: 25 }); +// => '{"name":"Bob","age":25}' -jsonStringToData.decode('~~invalid~~'); +jsonToObject.decode('~~invalid~~'); // ZodError: [ // { // "code": "invalid_format", // "format": "json", // "path": [], -// "message": "Invalid json" +// "message": "Unexpected token '~', \"~~invalid~~\" is not valid JSON" // } // ] - -myCodec.decode('{"name":"Alice","age":30}'); -// => { name: "Alice", age: 30 } - -myCodec.encode({ name: "Bob", age: 25 }); -// => '{"name":"Bob","age":25}' ``` ### `utf8ToBytes` @@ -485,8 +484,8 @@ Converts base64 strings to `Uint8Array` byte arrays and vice versa. ```ts const base64ToBytes = z.codec(z.base64(), z.instanceof(Uint8Array), { - decode: (base64String) => z.core.util.base64ToUint8Array(base64String), - encode: (bytes) => z.core.util.uint8ArrayToBase64(bytes), + decode: (base64String) => z.util.base64ToUint8Array(base64String), + encode: (bytes) => z.util.uint8ArrayToBase64(bytes), }); base64ToBytes.decode("SGVsbG8="); // => Uint8Array([72, 101, 108, 108, 111]) @@ -499,8 +498,8 @@ Converts base64url strings (URL-safe base64) to `Uint8Array` byte arrays. ```ts const base64urlToBytes = z.codec(z.base64url(), z.instanceof(Uint8Array), { - decode: (base64urlString) => z.core.util.base64urlToUint8Array(base64urlString), - encode: (bytes) => z.core.util.uint8ArrayToBase64url(bytes), + decode: (base64urlString) => z.util.base64urlToUint8Array(base64urlString), + encode: (bytes) => z.util.uint8ArrayToBase64url(bytes), }); base64urlToBytes.decode("SGVsbG8"); // => Uint8Array([72, 101, 108, 108, 111]) @@ -513,8 +512,8 @@ Converts hexadecimal strings to `Uint8Array` byte arrays and vice versa. ```ts const hexToBytes = z.codec(z.hex(), z.instanceof(Uint8Array), { - decode: (hexString) => z.core.util.hexToUint8Array(hexString), - encode: (bytes) => z.core.util.uint8ArrayToHex(bytes), + decode: (hexString) => z.util.hexToUint8Array(hexString), + encode: (bytes) => z.util.uint8ArrayToHex(bytes), }); hexToBytes.decode("48656c6c6f"); // => Uint8Array([72, 101, 108, 108, 111]) diff --git a/packages/zod/src/v4/classic/external.ts b/packages/zod/src/v4/classic/external.ts index 79d6f38d0b..285b11a7ad 100644 --- a/packages/zod/src/v4/classic/external.ts +++ b/packages/zod/src/v4/classic/external.ts @@ -27,6 +27,7 @@ export { flattenError, toJSONSchema, TimePrecision, + util, NEVER, } from "../core/index.js"; diff --git a/packages/zod/src/v4/classic/tests/codec-examples.test.ts b/packages/zod/src/v4/classic/tests/codec-examples.test.ts index f35dc6e6dc..4a19a882d2 100644 --- a/packages/zod/src/v4/classic/tests/codec-examples.test.ts +++ b/packages/zod/src/v4/classic/tests/codec-examples.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "vitest"; import * as z from "zod/v4"; // ============================================================================ -// Number/BigInt Codecs +// stringToNumber // ============================================================================ const stringToNumber = () => @@ -11,128 +11,6 @@ const stringToNumber = () => encode: (num) => num.toString(), }); -const stringToInt = () => - z.codec(z.string(), z.int(), { - decode: (str) => Number.parseInt(str, 10), - encode: (num) => num.toString(), - }); - -const stringToBigInt = () => - z.codec(z.string(), z.bigint(), { - decode: (str) => BigInt(str), - encode: (bigint) => bigint.toString(), - }); - -const numberToBigInt = () => - z.codec(z.int(), z.bigint(), { - decode: (num) => BigInt(num), - encode: (bigint) => Number(bigint), - }); - -// ============================================================================ -// Date/Duration Codecs -// ============================================================================ - -const isoDatetimeToDate = () => - z.codec(z.iso.datetime(), z.date(), { - decode: (isoString) => new Date(isoString), - encode: (date) => date.toISOString(), - }); - -const epochSecondsToDate = () => - z.codec(z.int().min(0), z.date(), { - decode: (seconds) => new Date(seconds * 1000), - encode: (date) => Math.floor(date.getTime() / 1000), - }); - -const epochMillisToDate = () => - z.codec(z.int().min(0), z.date(), { - decode: (millis) => new Date(millis), - encode: (date) => date.getTime(), - }); - -// ============================================================================ -// JSON Codec -// ============================================================================ - -const json = (schema: T) => - z.codec(z.string(), schema, { - decode: (jsonString) => JSON.parse(jsonString), - encode: (value) => JSON.stringify(value), - }); - -// ============================================================================ -// Text/Bytes Codecs -// ============================================================================ - -const utf8ToBytes = () => - z.codec(z.string(), z.instanceof(Uint8Array), { - decode: (str) => new TextEncoder().encode(str), - encode: (bytes) => new TextDecoder().decode(bytes), - }); - -const bytesToUtf8 = () => - z.codec(z.instanceof(Uint8Array), z.string(), { - decode: (bytes) => new TextDecoder().decode(bytes), - encode: (str) => new TextEncoder().encode(str), - }); - -// ============================================================================ -// Binary-to-text Codecs -// ============================================================================ - -// Using utility functions from z.core.util - -const base64 = () => - z.codec(z.base64(), z.instanceof(Uint8Array), { - decode: (base64String) => z.core.util.base64ToUint8Array(base64String), - encode: (bytes) => z.core.util.uint8ArrayToBase64(bytes), - }); - -const base64urlToBytes = () => - z.codec(z.base64url(), z.instanceof(Uint8Array), { - decode: (base64urlString) => z.core.util.base64urlToUint8Array(base64urlString), - encode: (bytes) => z.core.util.uint8ArrayToBase64url(bytes), - }); - -const hexToBytes = () => - z.codec(z.hex(), z.instanceof(Uint8Array), { - decode: (hexString) => z.core.util.hexToUint8Array(hexString), - encode: (bytes) => z.core.util.uint8ArrayToHex(bytes), - }); - -// ============================================================================ -// URL Codecs -// ============================================================================ - -const stringToURL = () => - z.codec(z.url(), z.instanceof(URL), { - decode: (urlString) => new URL(urlString), - encode: (url) => url.href, - }); - -const stringToHttpURL = () => - z.codec(z.httpUrl(), z.instanceof(URL), { - decode: (urlString) => new URL(urlString), - encode: (url) => url.href, - }); - -const uriComponent = () => - z.codec(z.string(), z.string(), { - decode: (encodedString) => decodeURIComponent(encodedString), - encode: (decodedString) => encodeURIComponent(decodedString), - }); - -// ============================================================================ -// Boolean Codec -// ============================================================================ - -const stringToBoolean = (options?: { truthy?: string[]; falsy?: string[] }) => z.stringbool(options); - -// ============================================================================ -// Tests -// ============================================================================ - test("stringToNumber codec", () => { const codec = stringToNumber(); @@ -152,6 +30,16 @@ test("stringToNumber codec", () => { expect(roundTrip).toBe("3.14159"); }); +// ============================================================================ +// stringToInt +// ============================================================================ + +const stringToInt = () => + z.codec(z.string(), z.int(), { + decode: (str) => Number.parseInt(str, 10), + encode: (num) => num.toString(), + }); + test("stringToInt codec", () => { const codec = stringToInt(); @@ -171,6 +59,16 @@ test("stringToInt codec", () => { expect(roundTrip).toBe("999"); }); +// ============================================================================ +// stringToBigInt +// ============================================================================ + +const stringToBigInt = () => + z.codec(z.string(), z.bigint(), { + decode: (str) => BigInt(str), + encode: (bigint) => bigint.toString(), + }); + test("stringToBigInt codec", () => { const codec = stringToBigInt(); @@ -190,6 +88,16 @@ test("stringToBigInt codec", () => { expect(roundTrip).toBe("987654321098765432109876543210"); }); +// ============================================================================ +// numberToBigInt +// ============================================================================ + +const numberToBigInt = () => + z.codec(z.int(), z.bigint(), { + decode: (num) => BigInt(num), + encode: (bigint) => Number(bigint), + }); + test("numberToBigInt codec", () => { const codec = numberToBigInt(); @@ -209,6 +117,16 @@ test("numberToBigInt codec", () => { expect(roundTrip).toBe(999); }); +// ============================================================================ +// isoDatetimeToDate +// ============================================================================ + +const isoDatetimeToDate = () => + z.codec(z.iso.datetime(), z.date(), { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString(), + }); + test("isoDatetimeToDate codec", () => { const codec = isoDatetimeToDate(); @@ -227,6 +145,16 @@ test("isoDatetimeToDate codec", () => { expect(roundTrip).toBe("2024-12-25T15:45:30.123Z"); }); +// ============================================================================ +// epochSecondsToDate +// ============================================================================ + +const epochSecondsToDate = () => + z.codec(z.int().min(0), z.date(), { + decode: (seconds) => new Date(seconds * 1000), + encode: (date) => Math.floor(date.getTime() / 1000), + }); + test("epochSecondsToDate codec", () => { const codec = epochSecondsToDate(); @@ -245,6 +173,16 @@ test("epochSecondsToDate codec", () => { expect(roundTrip).toBe(1640995200); }); +// ============================================================================ +// epochMillisToDate +// ============================================================================ + +const epochMillisToDate = () => + z.codec(z.int().min(0), z.date(), { + decode: (millis) => new Date(millis), + encode: (date) => date.getTime(), + }); + test("epochMillisToDate codec", () => { const codec = epochMillisToDate(); @@ -263,8 +201,30 @@ test("epochMillisToDate codec", () => { expect(roundTrip).toBe(1640995200123); }); +// ============================================================================ +// json +// ============================================================================ + +const jsonCodec = (schema: T) => + z.codec(z.string(), schema, { + decode: (jsonString, ctx) => { + try { + return JSON.parse(jsonString); + } catch (err: any) { + ctx.issues.push({ + code: "invalid_format", + format: "json", + input: jsonString, + message: err.message, + }); + return z.NEVER; + } + }, + encode: (value) => JSON.stringify(value), + }); + test("json codec", () => { - const codec = json(z.object({ name: z.string(), age: z.number() })); + const codec = jsonCodec(z.object({ name: z.string(), age: z.number() })); // Test decode const decoded = z.decode(codec, '{"name":"Alice","age":30}'); @@ -281,6 +241,16 @@ test("json codec", () => { expect(JSON.parse(roundTrip)).toEqual(JSON.parse(original)); }); +// ============================================================================ +// utf8ToBytes +// ============================================================================ + +const utf8ToBytes = () => + z.codec(z.string(), z.instanceof(Uint8Array), { + decode: (str) => new TextEncoder().encode(str), + encode: (bytes) => new TextDecoder().decode(bytes), + }); + test("utf8ToBytes codec", () => { const codec = utf8ToBytes(); @@ -299,6 +269,16 @@ test("utf8ToBytes codec", () => { expect(roundTrip).toBe(original); }); +// ============================================================================ +// bytesToUtf8 +// ============================================================================ + +const bytesToUtf8 = () => + z.codec(z.instanceof(Uint8Array), z.string(), { + decode: (bytes) => new TextDecoder().decode(bytes), + encode: (str) => new TextEncoder().encode(str), + }); + test("bytesToUtf8 codec", () => { const codec = bytesToUtf8(); @@ -318,6 +298,16 @@ test("bytesToUtf8 codec", () => { expect(roundTrip).toEqual(original); }); +// ============================================================================ +// base64 +// ============================================================================ + +const base64 = () => + z.codec(z.base64(), z.instanceof(Uint8Array), { + decode: (base64String) => z.util.base64ToUint8Array(base64String), + encode: (bytes) => z.util.uint8ArrayToBase64(bytes), + }); + test("base64 codec", () => { const codec = base64(); @@ -336,6 +326,16 @@ test("base64 codec", () => { expect(roundTrip).toBe(original); }); +// ============================================================================ +// base64urlToBytes +// ============================================================================ + +const base64urlToBytes = () => + z.codec(z.base64url(), z.instanceof(Uint8Array), { + decode: (base64urlString) => z.util.base64urlToUint8Array(base64urlString), + encode: (bytes) => z.util.uint8ArrayToBase64url(bytes), + }); + test("base64urlToBytes codec", () => { const codec = base64urlToBytes(); @@ -354,6 +354,16 @@ test("base64urlToBytes codec", () => { expect(roundTrip).toBe(original); }); +// ============================================================================ +// hexToBytes +// ============================================================================ + +const hexToBytes = () => + z.codec(z.hex(), z.instanceof(Uint8Array), { + decode: (hexString) => z.util.hexToUint8Array(hexString), + encode: (bytes) => z.util.uint8ArrayToHex(bytes), + }); + test("hexToBytes codec", () => { const codec = hexToBytes(); @@ -376,6 +386,16 @@ test("hexToBytes codec", () => { expect(roundTrip).toBe("deadbeef"); }); +// ============================================================================ +// stringToURL +// ============================================================================ + +const stringToURL = () => + z.codec(z.url(), z.instanceof(URL), { + decode: (urlString) => new URL(urlString), + encode: (url) => url.href, + }); + test("stringToURL codec", () => { const codec = stringToURL(); @@ -396,6 +416,16 @@ test("stringToURL codec", () => { expect(roundTrip).toBe(original); }); +// ============================================================================ +// stringToHttpURL +// ============================================================================ + +const stringToHttpURL = () => + z.codec(z.httpUrl(), z.instanceof(URL), { + decode: (urlString) => new URL(urlString), + encode: (url) => url.href, + }); + test("stringToHttpURL codec", () => { const codec = stringToHttpURL(); @@ -419,6 +449,16 @@ test("stringToHttpURL codec", () => { expect(roundTrip).toBe(original); }); +// ============================================================================ +// uriComponent +// ============================================================================ + +const uriComponent = () => + z.codec(z.string(), z.string(), { + decode: (encodedString) => decodeURIComponent(encodedString), + encode: (decodedString) => encodeURIComponent(decodedString), + }); + test("uriComponent codec", () => { const codec = uriComponent(); @@ -442,6 +482,12 @@ test("uriComponent codec", () => { expect(decodedComplex).toBe(complex); }); +// ============================================================================ +// stringToBoolean +// ============================================================================ + +const stringToBoolean = (options?: { truthy?: string[]; falsy?: string[] }) => z.stringbool(options); + test("stringToBoolean codec", () => { const codec = stringToBoolean(); @@ -469,6 +515,10 @@ test("stringToBoolean codec", () => { expect(z.encode(customCodec, false)).toBe("no"); }); +// ============================================================================ +// Error Handling Tests +// ============================================================================ + // Test error cases - these test input validation, not transform errors test("codec input validation", () => { // Test invalid base64 format @@ -495,25 +545,10 @@ test("codec input validation", () => { // Test transform errors - these test errors added by transform functions test("codec transform error handling", () => { // JSON codec that can fail during transform - const jsonCodec = z.codec(z.string(), z.json(), { - decode: (jsonString, ctx) => { - try { - return JSON.parse(jsonString); - } catch (err: any) { - ctx.issues.push({ - code: "invalid_format", - format: "json", - input: jsonString, - message: err.message, - }); - return z.NEVER; - } - }, - encode: (value) => JSON.stringify(value), - }); + const anyJSON = jsonCodec(z.json()); // Test successful JSON parsing - const validResult = z.safeDecode(jsonCodec, '{"valid": "json"}'); + const validResult = z.safeDecode(anyJSON, '{"valid": "json"}'); expect(validResult.success).toBe(true); if (validResult.success) { expect(validResult.data).toEqual({ valid: "json" }); @@ -521,7 +556,7 @@ test("codec transform error handling", () => { // Test invalid JSON that should create a single "invalid_format" issue // Verifies that the transform error aborts before reaching the output schema - const invalidResult = z.safeDecode(jsonCodec, '{"invalid":,}'); + const invalidResult = z.safeDecode(anyJSON, '{"invalid":,}'); expect(invalidResult.success).toBe(false); if (!invalidResult.success) { expect(invalidResult.error.issues).toMatchInlineSnapshot(` diff --git a/packages/zod/src/v4/mini/external.ts b/packages/zod/src/v4/mini/external.ts index b34748d8c9..9092dc4875 100644 --- a/packages/zod/src/v4/mini/external.ts +++ b/packages/zod/src/v4/mini/external.ts @@ -19,6 +19,7 @@ export { flattenError, toJSONSchema, TimePrecision, + util, NEVER, } from "../core/index.js"; diff --git a/play.ts b/play.ts index 4564a8e840..9e860152a3 100644 --- a/play.ts +++ b/play.ts @@ -1,3 +1,22 @@ import * as z from "zod"; z; +const json = (schema: T) => + z.codec(z.string(), schema, { + decode: (jsonString, ctx) => { + try { + return JSON.parse(jsonString); + } catch (err: any) { + ctx.issues.push({ + code: "invalid_format", + format: "json", + input: jsonString, + message: err.message, + }); + return z.NEVER; + } + }, + encode: (value) => JSON.stringify(value), + }); + +console.log(json(z.object({ name: z.string() })).parse(`{"name":"colin"}`)); From 3009fa8aeb90e00bfc35c7124f3e6f8cad11d290 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 25 Aug 2025 16:11:17 -0700 Subject: [PATCH 05/15] 4.1.2 --- packages/zod/jsr.json | 2 +- packages/zod/package.json | 2 +- packages/zod/src/v4/core/versions.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/zod/jsr.json b/packages/zod/jsr.json index f19c189fb9..bbfe1b178a 100644 --- a/packages/zod/jsr.json +++ b/packages/zod/jsr.json @@ -1,6 +1,6 @@ { "name": "@zod/zod", - "version": "4.1.1", + "version": "4.1.2", "exports": { "./package.json": "./package.json", ".": "./src/index.ts", diff --git a/packages/zod/package.json b/packages/zod/package.json index 3730c8fabd..109c8ac7f1 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "zod", - "version": "4.1.1", + "version": "4.1.2", "type": "module", "license": "MIT", "author": "Colin McDonnell ", diff --git a/packages/zod/src/v4/core/versions.ts b/packages/zod/src/v4/core/versions.ts index e177118ae5..23554d85a4 100644 --- a/packages/zod/src/v4/core/versions.ts +++ b/packages/zod/src/v4/core/versions.ts @@ -1,5 +1,5 @@ export const version = { major: 4, minor: 1, - patch: 1 as number, + patch: 2 as number, } as const; From 98ff675c313c15d3fa18b2bd01f844b1e817ee79 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 25 Aug 2025 16:28:47 -0700 Subject: [PATCH 06/15] Drop stringToBoolean --- packages/docs/content/codecs.mdx | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index 2477856745..e6a28e8570 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -561,23 +561,3 @@ const uriComponent = z.codec(z.string(), z.string(), { uriComponent.decode("Hello%20World%21"); // => "Hello World!" uriComponent.encode("Hello World!"); // => "Hello%20World!" ``` - -### `stringToBoolean()` - -Converts string representations to boolean values. This is an alias for `z.stringbool()`. - -```ts -const stringToBoolean = (options?: { truthy?: string[]; falsy?: string[] }) => - z.stringbool(options); - -const codec = stringToBoolean(); -codec.decode("true"); // => true -codec.decode("false"); // => false -codec.encode(true); // => "true" -codec.encode(false); // => "false" - -// With custom options -const customCodec = stringToBoolean({ truthy: ["yes", "y"], falsy: ["no", "n"] }); -customCodec.decode("yes"); // => true -customCodec.encode(true); // => "yes" -``` From a410616b39eedf3b4654c0c791b0ab1868996bfb Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 25 Aug 2025 16:55:22 -0700 Subject: [PATCH 07/15] Fix typo --- packages/docs/content/codecs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index e6a28e8570..319b2b5a6c 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -431,7 +431,7 @@ const jsonCodec = (schema: T) => Usage example with a specific schema: ```ts -const jsonToObject = json(z.object({ name: z.string(), age: z.number() })); +const jsonToObject = jsonCodec(z.object({ name: z.string(), age: z.number() })); jsonToObject.decode('{"name":"Alice","age":30}'); // => { name: "Alice", age: 30 } From 0cf45896edf8728b57c8e7f2b5a56536760afac1 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Tue, 26 Aug 2025 01:57:22 +0200 Subject: [PATCH 08/15] fix(v4): toJSONSchema - add missing oneOf inside items in tuple conversion (#5146) * fix(v4): toJSONSchema - add missing oneOf inside items in tuple conversion * oneOf -> anyOf --------- Co-authored-by: Colin McDonnell --- .../v4/classic/tests/to-json-schema.test.ts | 42 ++++++++++--------- packages/zod/src/v4/core/to-json-schema.ts | 6 ++- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index c0a206b2f1..ecf9e5475f 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -695,14 +695,16 @@ describe("toJSONSchema", () => { const schema = z.tuple([z.string(), z.number()]); expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(` { - "items": [ - { - "type": "string", - }, - { - "type": "number", - }, - ], + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "number", + }, + ], + }, "maxItems": 2, "minItems": 2, "type": "array", @@ -714,17 +716,19 @@ describe("toJSONSchema", () => { const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(` { - "items": [ - { - "type": "string", - }, - { - "type": "number", - }, - { - "type": "boolean", - }, - ], + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "number", + }, + { + "type": "boolean", + }, + ], + }, "minItems": 2, "type": "array", } diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index a18b2fcdc6..bbf3524e62 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -384,9 +384,11 @@ export class JSONSchemaGenerator { json.items = rest; } } else if (this.target === "openapi-3.0") { - json.items = [...prefixItems]; + json.items = { + anyOf: [...prefixItems], + }; if (rest) { - json.items.push(rest); + json.items.anyOf!.push(rest); } json.minItems = prefixItems.length; if (!rest) { From 8bf0c1639f0d3700f01fa8aaee2d8fa5d0abbe10 Mon Sep 17 00:00:00 2001 From: Elliot Schot Date: Tue, 26 Aug 2025 10:03:57 +1000 Subject: [PATCH 09/15] fix(v4): toJSONSchema tuple path handling for draft-7 with metadata IDs (#5152) * test(v4): add regression test for tuple draft-7 path handling issue #5151 Adds test that exposes bug where tuple schemas with rest elements and metadata IDs generate incorrect internal paths when targeting draft-7, causing improper schema extraction and reference generation. * fix(v4): toJSONSchema tuple path handling for draft-7 with metadata IDs Fixes issue #5151 where tuple schemas with rest elements and metadata IDs generated incorrect internal paths when targeting draft-7, causing improper schema extraction and reference generation. The bug was in hardcoded path names regardless of JSON Schema target: - draft-2020-12 should use "prefixItems" and "items" - draft-7/draft-4 should use "items" and "additionalItems" - openapi-3.0 should use indexed "items" --- .../v4/classic/tests/to-json-schema.test.ts | 101 +++++++++++++++++- packages/zod/src/v4/core/to-json-schema.ts | 12 ++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index ecf9e5475f..192c8c80ea 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -735,6 +735,95 @@ describe("toJSONSchema", () => { `); }); + test("tuple draft-7", () => { + const schema = z.tuple([z.string(), z.number()]); + expect(z.toJSONSchema(schema, { target: "draft-7", io: "input" })).toMatchInlineSnapshot(` + { + "$schema": "http://json-schema.org/draft-07/schema#", + "items": [ + { + "type": "string", + }, + { + "type": "number", + }, + ], + "type": "array", + } + `); + }); + + test("tuple with rest draft-7", () => { + const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); + expect(z.toJSONSchema(schema, { target: "draft-7", io: "input" })).toMatchInlineSnapshot(` + { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalItems": { + "type": "boolean", + }, + "items": [ + { + "type": "string", + }, + { + "type": "number", + }, + ], + "type": "array", + } + `); + }); + + test("tuple with rest draft-7 - issue #5151 regression test", () => { + // This test addresses issue #5151: tuple with rest elements and ids + // in draft-7 had incorrect internal path handling affecting complex scenarios + const primarySchema = z.string().meta({ id: "primary" }); + const restSchema = z.number().meta({ id: "rest" }); + const testSchema = z.tuple([primarySchema], restSchema); + + // Test both final output structure AND internal path handling + const capturedPaths: string[] = []; + const result = z.toJSONSchema(testSchema, { + target: "draft-7", + override: (ctx) => capturedPaths.push(ctx.path.join("/")), + }); + + // Verify correct draft-7 structure with metadata extraction + expect(result).toMatchInlineSnapshot(` + { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalItems": { + "$ref": "#/definitions/rest", + }, + "definitions": { + "primary": { + "id": "primary", + "type": "string", + }, + "rest": { + "id": "rest", + "type": "number", + }, + }, + "items": [ + { + "$ref": "#/definitions/primary", + }, + ], + "type": "array", + } + `); + + // Verify internal paths are correct (this was the actual bug) + expect(capturedPaths).toContain("items/0"); // prefix items should use "items" path + expect(capturedPaths).toContain("additionalItems"); // rest should use "additionalItems" path + expect(capturedPaths).not.toContain("prefixItems/0"); // should not use draft-2020-12 paths + + // Structural validations + expect(Array.isArray(result.items)).toBe(true); + expect(result.additionalItems).toBeDefined(); + }); + test("promise", () => { const schema = z.promise(z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` @@ -1558,7 +1647,9 @@ test("unrepresentable literal values are ignored", () => { } `); - const b = z.z.toJSONSchema(z.literal([undefined, null, 5, BigInt(1324)]), { unrepresentable: "any" }); + const b = z.z.toJSONSchema(z.literal([undefined, null, 5, BigInt(1324)]), { + unrepresentable: "any", + }); expect(b).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1570,7 +1661,9 @@ test("unrepresentable literal values are ignored", () => { } `); - const c = z.z.toJSONSchema(z.literal([undefined]), { unrepresentable: "any" }); + const c = z.z.toJSONSchema(z.literal([undefined]), { + unrepresentable: "any", + }); expect(c).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1853,7 +1946,9 @@ test("basic registry", () => { myRegistry.add(User, { id: "User" }); myRegistry.add(Post, { id: "Post" }); - const result = z.z.toJSONSchema(myRegistry, { uri: (id) => `https://example.com/${id}.json` }); + const result = z.z.toJSONSchema(myRegistry, { + uri: (id) => `https://example.com/${id}.json`, + }); expect(result).toMatchInlineSnapshot(` { "schemas": { diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index bbf3524e62..6c53c1899b 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -368,13 +368,21 @@ export class JSONSchemaGenerator { case "tuple": { const json: JSONSchema.ArraySchema = _json as any; json.type = "array"; + + const prefixPath = this.target === "draft-2020-12" ? "prefixItems" : "items"; + const restPath = + this.target === "draft-2020-12" ? "items" : this.target === "openapi-3.0" ? "items" : "additionalItems"; + const prefixItems = def.items.map((x, i) => - this.process(x, { ...params, path: [...params.path, "prefixItems", i] }) + this.process(x, { + ...params, + path: [...params.path, prefixPath, i], + }) ); const rest = def.rest ? this.process(def.rest, { ...params, - path: [...params.path, "items"], + path: [...params.path, restPath, ...(this.target === "openapi-3.0" ? [def.items.length] : [])], }) : null; From 5c5fa90e47df934acf6051a3ec30516baeef2eac Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Tue, 26 Aug 2025 02:10:19 +0200 Subject: [PATCH 10/15] fix(v4): toJSONSchema - wrong record output when targeting `openapi-3.0` (#5141) --- .../zod/src/v4/classic/tests/to-json-schema.test.ts | 12 ++++++++++++ packages/zod/src/v4/core/to-json-schema.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index 192c8c80ea..e603a5216e 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -652,6 +652,18 @@ describe("toJSONSchema", () => { `); }); + test("record openapi", () => { + const schema = z.record(z.string(), z.boolean()); + expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(` + { + "additionalProperties": { + "type": "boolean", + }, + "type": "object", + } + `); + }); + test("tuple", () => { const schema = z.tuple([z.string(), z.number()]); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index 6c53c1899b..c28ee0301d 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -421,7 +421,7 @@ export class JSONSchemaGenerator { case "record": { const json: JSONSchema.ObjectSchema = _json as any; json.type = "object"; - if (this.target !== "draft-4") { + if (this.target === "draft-7" || this.target === "draft-2020-12") { json.propertyNames = this.process(def.keyType, { ...params, path: [...params.path, "propertyNames"], From 87b97ccd556e6d8dc6b4f4455762cc7405b0abc9 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:10:33 +0900 Subject: [PATCH 11/15] docs(codecs): update example to use payloadSchema (#5150) --- packages/docs/content/codecs.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index 319b2b5a6c..d7f68582ba 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -66,10 +66,10 @@ In these cases, `z.decode()` and `z.encode()` behave quite differently. ```ts const payloadSchema = z.object({ startDate: stringToDate }); -stringToDate.decode("2024-01-15T10:30:00.000Z") +payloadSchema.decode("2024-01-15T10:30:00.000Z") // => Date -stringToDate.encode(new Date("2024-01-15T10:30:00.000Z")) +payloadSchema.encode(new Date("2024-01-15T10:30:00.000Z")) // => string ``` From 309f3584fd9a3856c3e0c813196210ba4b11d2a9 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Tue, 26 Aug 2025 02:10:58 +0200 Subject: [PATCH 12/15] fix(v4): toJSONSchema - output numbers with exclusive range correctly when targeting `openapi-3.0` (#5139) --- .../zod/src/v4/classic/tests/to-json-schema.test.ts | 13 +++++++++++++ packages/zod/src/v4/core/to-json-schema.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index e603a5216e..ec6267cf37 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -571,6 +571,19 @@ describe("toJSONSchema", () => { `); }); + test("number with exclusive min-max openapi", () => { + const schema = z.number().lt(100).gt(1); + expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(` + { + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "maximum": 100, + "minimum": 1, + "type": "number", + } + `); + }); + test("arrays", () => { expect(z.toJSONSchema(z.array(z.string()))).toMatchInlineSnapshot(` { diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index c28ee0301d..0f5e40030c 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -183,7 +183,7 @@ export class JSONSchemaGenerator { else json.type = "number"; if (typeof exclusiveMinimum === "number") { - if (this.target === "draft-4") { + if (this.target === "draft-4" || this.target === "openapi-3.0") { json.minimum = exclusiveMinimum; json.exclusiveMinimum = true; } else { @@ -199,7 +199,7 @@ export class JSONSchemaGenerator { } if (typeof exclusiveMaximum === "number") { - if (this.target === "draft-4") { + if (this.target === "draft-4" || this.target === "openapi-3.0") { json.maximum = exclusiveMaximum; json.exclusiveMaximum = true; } else { From 1e71ca99b92b94aac8ca45694f28864ae1654135 Mon Sep 17 00:00:00 2001 From: Parbez Date: Tue, 26 Aug 2025 05:41:38 +0530 Subject: [PATCH 13/15] docs: fix refine fn to encode works properly (#5148) --- packages/docs/content/codecs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/codecs.mdx b/packages/docs/content/codecs.mdx index d7f68582ba..a6452aeeda 100644 --- a/packages/docs/content/codecs.mdx +++ b/packages/docs/content/codecs.mdx @@ -191,7 +191,7 @@ During regular decoding, a `ZodPipe` schema will first parse the data with All checks (`.refine()`, `.min()`, `.max()`, etc.) are still executed in both directions. ```ts -const schema = stringToDate.refine((date) => date.getFullYear() > 2000, "Must be this millenium"); +const schema = stringToDate.refine((date) => date.getFullYear() >= 2000, "Must be this millenium"); schema.encode(new Date("2000-01-01")); // => Date From a85ec3c73c6a98a496f5dd3b6fb19ebe07e227f9 Mon Sep 17 00:00:00 2001 From: Howard Guo <50100922+toto6038@users.noreply.github.com> Date: Tue, 26 Aug 2025 08:12:05 +0800 Subject: [PATCH 14/15] fix(docs): correct example to use `LooseDog` instead of `Dog` (#5136) --- packages/docs/content/api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/api.mdx b/packages/docs/content/api.mdx index a0936948af..12cb0fa79c 100644 --- a/packages/docs/content/api.mdx +++ b/packages/docs/content/api.mdx @@ -1099,7 +1099,7 @@ const LooseDog = z.looseObject({ name: z.string(), }); -Dog.parse({ name: "Yeller", extraKey: true }); +LooseDog.parse({ name: "Yeller", extraKey: true }); // => { name: "Yeller", extraKey: true } ``` From 3e982743f3abba6a421abb899670f70e49284af4 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 25 Aug 2025 17:54:57 -0700 Subject: [PATCH 15/15] 4.1.3 --- packages/zod/jsr.json | 2 +- packages/zod/package.json | 2 +- packages/zod/src/v4/core/versions.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/zod/jsr.json b/packages/zod/jsr.json index bbfe1b178a..891b740d96 100644 --- a/packages/zod/jsr.json +++ b/packages/zod/jsr.json @@ -1,6 +1,6 @@ { "name": "@zod/zod", - "version": "4.1.2", + "version": "4.1.3", "exports": { "./package.json": "./package.json", ".": "./src/index.ts", diff --git a/packages/zod/package.json b/packages/zod/package.json index 109c8ac7f1..12788c808b 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "zod", - "version": "4.1.2", + "version": "4.1.3", "type": "module", "license": "MIT", "author": "Colin McDonnell ", diff --git a/packages/zod/src/v4/core/versions.ts b/packages/zod/src/v4/core/versions.ts index 23554d85a4..cdb3fce849 100644 --- a/packages/zod/src/v4/core/versions.ts +++ b/packages/zod/src/v4/core/versions.ts @@ -1,5 +1,5 @@ export const version = { major: 4, minor: 1, - patch: 2 as number, + patch: 3 as number, } as const;