💎 Zod 4 is now stable!  Read the announcement.
Zod logo

发布说明

经过一年的积极开发,Zod 4 现已稳定发布!它更快、更轻量、对 tsc 更友好,并且实现了一些长期要求的功能。

❤️

非常感谢 Clerk,他们通过非常慷慨的 OSS Fellowship 支持了我对 Zod 4 的工作。在整个(比预期长得多的!)开发过程中,他们都是令人惊叹的合作伙伴。

版本控制

安装:

npm install zod@^4.0.0

关于破坏性变更的完整列表,请参考 迁移指南。本文重点介绍新功能和增强。

为什么要发布新的主要版本?

Zod v3.0 发布于 2021 年 5 月(!)。当时 Zod 在 GitHub 上有 2700 个 stars 和每周 60 万次下载。如今它拥有 37.8k stars 和每周 3100 万次下载(比 6 周前 beta 版发布时的 2300 万有所增加!)。经过 24 个次要版本,Zod 3 的代码库已达到瓶颈;最常请求的功能和改进需要破坏性变更。

Zod 4 一举解决了 Zod 3 的许多长期存在的设计限制,为几个长期要求的功能和性能的巨大飞跃铺平了道路。它解决了 Zod 10 个投票最多的未解决问题 中的 9 个。如果运气好的话,它将成为未来更多年的新基础。

欲了解新功能的快速分类,请参阅目录。点击任何项目即可跳转到该部分。

基准测试

你可以在 Zod 仓库中自行运行这些基准测试:

$ git clone [email protected]:colinhacks/zod.git
$ cd zod
$ git switch v4
$ pnpm install

然后运行特定的基准测试:

$ pnpm bench <name>

字符串解析速度提高 14 倍

$ pnpm bench string
runtime: node v22.13.0 (arm64-darwin)
 
benchmark      time (avg)             (min max)       p75       p99      p999
------------------------------------------------- -----------------------------
 z.string().parse
------------------------------------------------- -----------------------------
zod3          363 µs/iter       (338 µs 683 µs)    351 µs    467 µs    572 µs
zod4       24'674 ns/iter    (21'083 ns 235 µs) 24'209 ns 76'125 ns    120 µs
 
summary for z.string().parse
  zod4
   14.71x faster than zod3

数组解析速度提高 7 倍

$ pnpm bench array
runtime: node v22.13.0 (arm64-darwin)
 
benchmark      time (avg)             (min max)       p75       p99      p999
------------------------------------------------- -----------------------------
 z.array() parsing
------------------------------------------------- -----------------------------
zod3          147 µs/iter       (137 µs 767 µs)    140 µs    246 µs    520 µs
zod4       19'817 ns/iter    (18'125 ns 436 µs) 19'125 ns 44'500 ns    137 µs
 
summary for z.array() parsing
  zod4
   7.43x faster than zod3

对象解析速度提高 6.5 倍

这运行 Moltar validation library benchmark

$ pnpm bench object-moltar
benchmark      time (avg)             (min max)       p75       p99      p999
------------------------------------------------- -----------------------------
 z.object() safeParse
------------------------------------------------- -----------------------------
zod3          805 µs/iter     (771 µs 2'802 µs)    804 µs    928 µs  2'802 µs
zod4          124 µs/iter     (118 µs 1'236 µs)    119 µs    231 µs    329 µs
 
summary for z.object() safeParse
  zod4
   6.5x faster than zod3

tsc 实例化减少 100 倍

考虑以下简单的文件:

import * as z from "zod";
 
export const A = z.object({
  a: z.string(),
  b: z.string(),
  c: z.string(),
  d: z.string(),
  e: z.string(),
});
 
export const B = A.extend({
  f: z.string(),
  g: z.string(),
  h: z.string(),
});

使用 "zod/v3" 运行 tsc --extendedDiagnostics 编译此文件会导致超过 25000 次类型实例化。而使用 "zod/v4" 仅产生约 175 次。

Zod 仓库包含一个 tsc 基准测试游乐场。你可以使用 packages/tsc 中的编译器基准测试自行尝试。确切的数字可能会随着实现的演变而变化。

$ cd packages/tsc
$ pnpm bench object-with-extend

更重要的是,Zod 4 重新设计并简化了 ZodObject 和其他 schema 类的泛型,以避免一些恶性的“实例化爆炸”。例如,重复链式调用 .extend().omit()——这在以前会导致编译器问题:

import * as z from "zod";
 
export const a = z.object({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const b = a.omit({
  a: true,
  b: true,
  c: true,
});
 
export const c = b.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const d = c.omit({
  a: true,
  b: true,
  c: true,
});
 
export const e = d.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const f = e.omit({
  a: true,
  b: true,
  c: true,
});
 
export const g = f.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const h = g.omit({
  a: true,
  b: true,
  c: true,
});
 
export const i = h.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const j = i.omit({
  a: true,
  b: true,
  c: true,
});
 
export const k = j.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const l = k.omit({
  a: true,
  b: true,
  c: true,
});
 
export const m = l.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const n = m.omit({
  a: true,
  b: true,
  c: true,
});
 
export const o = n.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const p = o.omit({
  a: true,
  b: true,
  c: true,
});
 
export const q = p.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});

在 Zod 3 中,这需要 4000ms 来编译;添加更多的 .extend() 调用会触发“可能无限”的错误。在 Zod 4 中,这在 400ms 内编译完成,快了 10x

结合即将推出的 tsgo 编译器,Zod 4 的编辑器性能将扩展到更庞大的 schema 和代码库。

核心包大小减少 2 倍

考虑以下简单的脚本。

import * as z from "zod";
 
const schema = z.boolean();
 
schema.parse(true);

这大概是验证中最简单的情况了。这是有意的;这是衡量 核心包大小 的好方法——即即使在简单情况下也会包含在包中的代码。我们将使用 rollup 分别打包 Zod 3 和 Zod 4 并比较最终的包。

PackageBundle (gzip)
Zod 312.47kb
Zod 45.36kb

Zod 4 的核心包大约小 57% (2.3x)。这很好!但我们可以做得更好。

介绍 Zod Mini

Zod 的重方法 API 在根本上很难 tree-shake。即使是我们简单的 z.boolean() 脚本也会引入一堆我们未使用的包括 .optional().array() 等方法的实现。编写更精简的实现只能走到这一步。这就是 Zod Mini 的用武之地。

npm install zod@^4.0.0

它是 Zod 的一个变体,具有函数式的、可 tree-shake 的 API,与 zod 一一对应。Zod 使用方法的地方,Zod Mini 通常使用包装函数:

import * as z from "zod/mini";
 
z.optional(z.string());
 
z.union([z.string(), z.number()]);
 
z.extend(z.object({ /* ... */ }), { age: z.number() });

并非所有方法都消失了!解析方法在 Zod 和 Zod Mini 中是相同的:

import * as z from "zod/mini";
 
z.string().parse("asdf");
z.string().safeParse("asdf");
await z.string().parseAsync("asdf");
await z.string().safeParseAsync("asdf");

还有一个通用的 .check() 方法用于添加细化 (refinements)。

import * as z from "zod/mini";
 
z.array(z.number()).check(
  z.minLength(5), 
  z.maxLength(10),
  z.refine(arr => arr.includes(5))
);

Zod Mini 中提供了以下顶级细化。它们对应的 Zod 方法应该是不言自明的。

import * as z from "zod/mini";
 
// custom checks
z.refine();
 
// first-class checks
z.lt(value);
z.lte(value); // alias: z.maximum()
z.gt(value);
z.gte(value); // alias: z.minimum()
z.positive();
z.negative();
z.nonpositive();
z.nonnegative();
z.multipleOf(value);
z.maxSize(value);
z.minSize(value);
z.size(value);
z.maxLength(value);
z.minLength(value);
z.length(value);
z.regex(regex);
z.lowercase();
z.uppercase();
z.includes(value);
z.startsWith(value);
z.endsWith(value);
z.property(key, schema); // for object schemas; check `input[key]` against `schema`
z.mime(value); // for file schemas (see below)
 
// overwrites (these *do not* change the inferred type!)
z.overwrite(value => newValue);
z.normalize();
z.trim();
z.toLowerCase();
z.toUpperCase();

这种更函数式的 API 使打包器更容易 tree-shake 掉你未使用的 API。虽然对于大多数用例仍建议使用常规 Zod,但任何具有非同寻常的严格包大小限制的项目都应考虑 Zod Mini。

核心包大小减少 6.6 倍

这是上面的脚本,已更新为使用 "zod/mini" 而不是 "zod"

import * as z from "zod/mini";
 
const schema = z.boolean();
schema.parse(false);

当我们使用 rollup 构建它时,gzip 压缩后的包大小为 1.88kb。与 zod@3 相比,核心包大小减少了 85% (6.6x)。

PackageBundle (gzip)
Zod 312.47kb
Zod 4 (regular)5.36kb
Zod 4 (mini)1.88kb

欲了解更多信息,请参阅专门的 zod/mini 文档页面。完整的 API 细节混合在现有的文档页面中;在 API 不同的地方,代码块包含 "Zod""Zod Mini" 的单独标签页。

元数据 (Metadata)

Zod 4 引入了一个新系统,用于向 schema 添加强类型的元数据。元数据不存储在 schema 本身内部;而是存储在一个“schema 注册表”中,该注册表将 schema 与某些类型化的元数据相关联。使用 z.registry() 创建注册表:

import * as z from "zod";
 
const myRegistry = z.registry<{ title: string; description: string }>();

要将 schema 添加到你的注册表:

const emailSchema = z.string().email();
 
myRegistry.add(emailSchema, { title: "Email address", description: "..." });
myRegistry.get(emailSchema);
// => { title: "Email address", ... }

或者,为了方便,你可以在 schema 上使用 .register() 方法:

emailSchema.register(myRegistry, { title: "Email address", description: "..." })
// => returns emailSchema

全局注册表

Zod 还导出一个全局注册表 z.globalRegistry,它接受一些通用的 JSON Schema 兼容的元数据:

z.globalRegistry.add(z.string(), { 
  id: "email_address",
  title: "Email address",
  description: "Provide your email",
  examples: ["[email protected]"],
  extraKey: "Additional properties are also allowed"
});

.meta()

为了方便地将 schema 添加到 z.globalRegistry,请使用 .meta() 方法。

z.string().meta({ 
  id: "email_address",
  title: "Email address",
  description: "Provide your email",
  examples: ["[email protected]"],
  // ...
});

为了与 Zod 3 兼容,.describe() 仍然可用,但首选 .meta()

z.string().describe("An email address");
 
// equivalent to
z.string().meta({ description: "An email address" });

JSON Schema 转换

Zod 4 通过 z.toJSONSchema() 引入了第一方 JSON Schema 转换。

import * as z from "zod";
 
const mySchema = z.object({name: z.string(), points: z.number()});
 
z.toJSONSchema(mySchema);
// => {
//   type: "object",
//   properties: {
//     name: {type: "string"},
//     points: {type: "number"},
//   },
//   required: ["name", "points"],
// }

z.globalRegistry 中的任何元数据都会自动包含在 JSON Schema 输出中。

const mySchema = z.object({
  firstName: z.string().describe("Your first name"),
  lastName: z.string().meta({ title: "last_name" }),
  age: z.number().meta({ examples: [12, 99] }),
});
 
z.toJSONSchema(mySchema);
// => {
//   type: 'object',
//   properties: {
//     firstName: { type: 'string', description: 'Your first name' },
//     lastName: { type: 'string', title: 'last_name' },
//     age: { type: 'number', examples: [ 12, 99 ] }
//   },
//   required: [ 'firstName', 'lastName', 'age' ]
// }

有关自定义生成的 JSON Schema 的信息,请参阅 JSON Schema 文档

递归对象

这是一个意外的惊喜。经过多年尝试解决这个问题,我终于 找到了一个方法 来正确推断 Zod 中的递归对象类型。定义递归类型:

const Category = z.object({
  name: z.string(),
  get subcategories(){
    return z.array(Category)
  }
});
 
type Category = z.infer<typeof Category>;
// { name: string; subcategories: Category[] }

你也可以表示 相互递归类型

const User = z.object({
  email: z.email(),
  get posts(){
    return z.array(Post)
  }
});
 
const Post = z.object({
  title: z.string(),
  get author(){
    return User
  }
});

与 Zod 3 的递归类型模式不同,不需要类型转换。生成的 schema 是普通的 ZodObject 实例,并且具有可用的完整方法集。

Post.pick({ title: true })
Post.partial();
Post.extend({ publishDate: z.date() });

文件 Schemas

要验证 File 实例:

const fileSchema = z.file();
 
fileSchema.min(10_000); // minimum .size (bytes)
fileSchema.max(1_000_000); // maximum .size (bytes)
fileSchema.mime(["image/png"]); // MIME type

国际化

Zod 4 引入了一个新的 locales API,用于将错误消息全局翻译成不同语言。

import * as z from "zod";
 
// configure English locale (default)
z.config(z.locales.en());

请参阅 错误自定义 中的完整支持语言列表;随着新语言的加入,该部分将始终更新。

错误美化打印

zod-validation-error 包的流行证明了对官方美化打印错误 API 的巨大需求。如果你正在使用该包,请务必继续使用。

Zod 现在实现了一个顶级 z.prettifyError 函数,用于将 ZodError 转换为用户友好的格式化字符串。

const myError = new z.ZodError([
  {
    code: 'unrecognized_keys',
    keys: [ 'extraField' ],
    path: [],
    message: 'Unrecognized key: "extraField"'
  },
  {
    expected: 'string',
    code: 'invalid_type',
    path: [ 'username' ],
    message: 'Invalid input: expected string, received number'
  },
  {
    origin: 'number',
    code: 'too_small',
    minimum: 0,
    inclusive: true,
    path: [ 'favoriteNumbers', 1 ],
    message: 'Too small: expected number to be >=0'
  }
]);
 
z.prettifyError(myError);

这将返回以下可美化打印的多行字符串:

✖ Unrecognized key: "extraField"
✖ Invalid input: expected string, received number
  → at username
✖ Invalid input: expected number, received string
  → at favoriteNumbers[1]

目前格式不可配置;这可能在未来发生变化。

顶级字符串格式

所有“字符串格式”(email 等)都已提升为 z 模块上的顶级函数。这既更简洁,又更易于 tree-shake。方法等价物(z.string().email() 等)仍然可用,但已弃用。它们将在下一个主要版本中删除。

z.email();
z.uuidv4();
z.uuidv7();
z.uuidv8();
z.ipv4();
z.ipv6();
z.cidrv4();
z.cidrv6();
z.url();
z.e164();
z.base64();
z.base64url();
z.jwt();
z.lowercase();
z.iso.date();
z.iso.datetime();
z.iso.duration();
z.iso.time();

自定义 Email 正则表达式

z.email() API 现在支持自定义正则表达式。没有一种规范的 email 正则;不同的应用程序可能会选择更严格或更宽松。为了方便,Zod 导出了一些通用的正则。

// Zod's default email regex (Gmail rules)
// see colinhacks.com/essays/reasonable-email-regex
z.email(); // z.regexes.email
 
// the regex used by browsers to validate input[type=email] fields
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email
z.email({ pattern: z.regexes.html5Email });
 
// the classic emailregex.com regex (RFC 5322)
z.email({ pattern: z.regexes.rfc5322Email });
 
// a loose regex that allows Unicode (good for intl emails)
z.email({ pattern: z.regexes.unicodeEmail });

模板字面量类型

Zod 4 实现了 z.templateLiteral()。模板字面量类型可能是 TypeScript 类型系统中以前无法表示的最大功能。

const hello = z.templateLiteral(["hello, ", z.string()]);
// `hello, ${string}`
 
const cssUnits = z.enum(["px", "em", "rem", "%"]);
const css = z.templateLiteral([z.number(), cssUnits]);
// `${number}px` | `${number}em` | `${number}rem` | `${number}%`
 
const email = z.templateLiteral([
  z.string().min(1),
  "@",
  z.string().max(64),
]);
// `${string}@${string}` (the min/max refinements are enforced!)

每个可以字符串化的 Zod schema 类型都存储一个内部正则:字符串、字符串格式如 z.email()、数字、布尔值、bigint、枚举、字面量、undefined/optional、null/nullable 和其他模板字面量。z.templateLiteral 构造函数将这些连接成一个超级正则,因此像字符串格式 (z.email()) 这样的东西会被正确强制执行(但自定义细化不会!)。

阅读 模板字面量文档 了解更多信息。

数字格式

添加了新的数字“格式”来表示固定宽度的整数和浮点类型。这些返回一个 ZodNumber 实例,并通过已添加的适当最小/最大约束。

z.int();      // [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
z.float32();  // [-3.4028234663852886e38, 3.4028234663852886e38]
z.float64();  // [-1.7976931348623157e308, 1.7976931348623157e308]
z.int32();    // [-2147483648, 2147483647]
z.uint32();   // [0, 4294967295]

同样,也添加了以下 bigint 数字格式。这些整数类型超出了 JavaScript 中 number 可以安全表示的范围,因此这些返回一个 ZodBigInt 实例,并通过已添加的适当最小/最大约束。

z.int64();    // [-9223372036854775808n, 9223372036854775807n]
z.uint64();   // [0n, 18446744073709551615n]

Stringbool

现有的 z.coerce.boolean() API 非常简单:falsy 值(false, undefined, null, 0, "", NaN 等)变为 false,truthy 值变为 true

这仍然是一个很好的 API,其行为与其他 z.coerce API 一致。但一些用户请求更复杂的“env 风格”布尔强制转换。为了支持这一点,Zod 4 引入了 z.stringbool()

const strbool = z.stringbool();
 
strbool.parse("true")         // => true
strbool.parse("1")            // => true
strbool.parse("yes")          // => true
strbool.parse("on")           // => true
strbool.parse("y")            // => true
strbool.parse("enabled")      // => true
 
strbool.parse("false");       // => false
strbool.parse("0");           // => false
strbool.parse("no");          // => false
strbool.parse("off");         // => false
strbool.parse("n");           // => false
strbool.parse("disabled");    // => false
 
strbool.parse(/* anything else */); // ZodError<[{ code: "invalid_value" }]>

要自定义 truthy 和 falsy 值:

z.stringbool({
  truthy: ["yes", "true"],
  falsy: ["no", "false"]
})

参阅 z.stringbool() 文档 了解更多信息。

简化的错误自定义

Zod 4 中的大多数破坏性变更都涉及 错误自定义 API。它们在 Zod 3 中有点混乱;Zod 4 使事情变得更加优雅,我认为这值得在这里强调。

长话短说,现在有一个统一的 error 参数用于自定义错误,以替换以下 API:

message 替换为 error。(message 参数仍然支持但已弃用。)

- z.string().min(5, { message: "Too short." });
+ z.string().min(5, { error: "Too short." });

invalid_type_errorrequired_error 替换为 error(函数语法):

// Zod 3
- z.string({ 
-   required_error: "This field is required" 
-   invalid_type_error: "Not a string", 
- });
 
// Zod 4 
+ z.string({ error: (issue) => issue.input === undefined ? 
+  "This field is required" :
+  "Not a string" 
+ });

errorMap 替换为 error(函数语法):

// Zod 3 
- z.string({
-   errorMap: (issue, ctx) => {
-     if (issue.code === "too_small") {
-       return { message: `Value must be >${issue.minimum}` };
-     }
-     return { message: ctx.defaultError };
-   },
- });
 
// Zod 4
+ z.string({
+   error: (issue) => {
+     if (issue.code === "too_small") {
+       return `Value must be >${issue.minimum}`
+     }
+   },
+ });

升级的 z.discriminatedUnion()

受歧视联合 (Discriminated unions) 现在支持许多以前不支持的 schema 类型,包括联合 (unions) 和管道 (pipes):

const MyResult = z.discriminatedUnion("status", [
  // simple literal
  z.object({ status: z.literal("aaa"), data: z.string() }),
  // union discriminator
  z.object({ status: z.union([z.literal("bbb"), z.literal("ccc")]) }),
  // pipe discriminator
  z.object({ status: z.literal("fail").transform(val => val.toUpperCase()) }),
]);

也许最重要的是,受歧视联合现在可以 组合——你可以使用一个受歧视联合作为另一个的成员。

const BaseError = z.object({ status: z.literal("failed"), message: z.string() });
 
const MyResult = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: z.string() }),
  z.discriminatedUnion("code", [
    BaseError.extend({ code: z.literal(400) }),
    BaseError.extend({ code: z.literal(401) }),
    BaseError.extend({ code: z.literal(500) })
  ])
]);

z.literal() 中的多个值

z.literal() API 现在可选地支持多个值。

const httpCodes = z.literal([ 200, 201, 202, 204, 206, 207, 208, 226 ]);
 
// previously in Zod 3:
const httpCodes = z.union([
  z.literal(200),
  z.literal(201),
  z.literal(202),
  z.literal(204),
  z.literal(206),
  z.literal(207),
  z.literal(208),
  z.literal(226)
]);

细化 (Refinements) 存在于 Schema 内部

在 Zod 3 中,它们存储在包装原始 schema 的 ZodEffects 类中。这很不方便,这意味着你不能将 .refine() 与其他 schema 方法(如 .min())交错使用。

z.string()
  .refine(val => val.includes("@"))
  .min(5);
// ^ ❌ Property 'min' does not exist on type ZodEffects<ZodString, string, string>

在 Zod 4 中,细化存储在 schema 本身内部,因此上面的代码按预期工作。

z.string()
  .refine(val => val.includes("@"))
  .min(5); // ✅

.overwrite()

.transform() 方法非常有用,但它有一个主要缺点:输出类型在运行时不再是 可内省的。转换函数是一个可以返回任何东西的黑盒。这意味着(除其他外)没有可靠的方法将 schema 转换为 JSON Schema。

const Squared = z.number().transform(val => val ** 2);
// => ZodPipe<ZodNumber, ZodTransform>

Zod 4 引入了一个新的 .overwrite() 方法来表示 不改变推断类型 的转换。与 .transform() 不同,此方法返回原始类的实例。overwrite 函数存储为细化,因此它不会(也不能)修改推断类型。

z.number().overwrite(val => val ** 2).max(100);
// => ZodNumber

现有的 .trim(), .toLowerCase().toUpperCase() 方法已使用 .overwrite() 重新实现。

可扩展的基础:zod/v4/core

虽然这与大多数 Zod 用户无关,但值得强调。Zod Mini 的添加需要创建一个共享的子包 zod/v4/core,其中包含 Zod 和 Zod Mini 之间共享的核心功能。

起初我很抵触这一点,但现在我认为它是 Zod 4 最重要的功能之一。它让 Zod 从一个简单的库升级为一个快速验证“基底”,可以撒入其他库中。

如果你正在构建一个 schema 库,请参考 Zod 和 Zod Mini 的实现,了解如何构建在 zod/v4/core 提供的基础之上。如果有需要帮助或反馈,请不要犹豫,在 GitHub discussions 或通过 X/Bluesky 联系。

总结

我计划写一系列额外的文章来解释 Zod Mini 等主要功能背后的设计过程。随着这些文章的发布,我会更新此部分。

对于库作者,现在有一个专门的 For library authors 指南,描述了在 Zod 之上构建的最佳实践。它回答了有关如何同时支持 Zod 3 和 Zod 4(包括 Mini)的常见问题。

pnpm upgrade zod@latest

解析愉快!
— Colin McDonnell @colinhacks