💎 雨云 RainYun - 企业级云计算服务提供商

Zod 4.5

Colin McDonnell·

Zod 4.5 现已发布

npm install zod@latest

概览:

z.compile()

现在可以使用 z.compile(schema) 预编译任意 Zod schema。这会显著提升解析性能。

import * as z from "zod";
 
const Player = z.object({
  username: z.string(),
  bio: z.string(),
  xp: z.number(),
  // ...20 more properties...
});
 
const CompiledPlayer = z.compile(Player);

编译后的 schema 可以完全像未编译的 schema 一样使用。对于编译后的 schema 没有任何特殊规则。它们只是更快。

Player.parse({ ... });
CompiledPlayer.parse({ ... }); // ~9x faster

对于对象、数组和联合类型,解析速度可提升约 3–9 倍。越复杂的 schema,收益通常越大。

在共享的纳秒坐标轴上显示每次解析耗时,标准解析器为灰色条,编译后的耗时显示为其中的蓝色条:包含 10 个对象的数组从 377 ns 降至 68 ns(5.5 倍),20 键对象从 301 ns 降至 38 ns(7.8 倍),包含 10 个字符串的数组从 241 ns 降至 33 ns(7.3 倍),3 个对象的联合类型从 190 ns 降至 36 ns(5.3 倍),3 元组从 119 ns 降至 33 ns(3.6 倍),5 键严格对象从 117 ns 降至 32 ns(3.7 倍),判别联合从 92 ns 降至 27 ns(3.4 倍),5 键对象从 76 ns 降至 28 ns(2.8 倍);编译后最多快 7.8 倍
按 schema 类型划分的每次解析耗时,标准解析器与编译后对比——越低越好(基准测试

下面是 Moltar 基准测试结果,将 Zod(编译和未编译)与 Moltar ParseSafe 基准进行比较。

Moltar 基准测试 fixture 上每秒操作数的柱状图,parseSafe 类别:Zod 4 编译后 47.5M,typia 45.3M,Zod 4 11.6M,valibot 1.8M,effect 1.7M,Zod 3 1.2M,arktype 152k,yup 121k
Moltar 基准测试 fixture 上的吞吐量(parseSafe:返回一个移除未知键的新对象)——越高越好(基准测试

以下是 Moltar AssertLoose 基准的对应结果。测试使用了新的 z.validate(schema, input) 函数(将在本文后面详细介绍)。

Moltar 基准测试 fixture 上每秒操作数的柱状图,assertLoose 类别:typia 74.9M,arktype 66.2M,Zod 4 编译后 60.6M,Zod 4 6.5M,valibot 1.9M,effect 1.7M,Zod 3 1.2M,yup 124k
Moltar 基准测试 fixture 上的吞吐量(assertLoose:返回一个布尔值,允许未知键)——越高越好(基准测试

Zod 的整个测试套件会运行两次——一次正常运行,另一次在全局启用自动编译的情况下运行,以确保完全一致。

import "zod/compile"

要在应用中编译每个 schema,请在入口点顶部导入一次 zod/compile。在该导入之后构造的每个 schema,都会在第一次用于解析数据时自动编译

import "zod/compile"; // must come before modules that define schemas
import * as z from "zod";
 
const schema = z.object({ name: z.string() });
schema.parse({ name: "ok" }); // compiled on first parse

它也可以作为 Node.js CLI 标志使用,这可以保证它在任何模块定义 schema 之前运行:

node --import zod/compile app.js

或者在 bunfig.tomlnub.jsonc 中设置 preload

nub.jsonc
{
  "preload": ["zod/compile"]
}

所有 schema 都会在不同程度上受益,不过复杂的对象/元组/数组 schema 比简单的标量验证器受益更多。

阅读文档,或完整的技术文章:Introducing z.compile()

z.creditCard()

一种新的字符串格式:12–19 位数字,可选择使用单个空格或连字符分隔,并带有有效的 Luhn 校验和。(#5931

z.creditCard().parse("4111 1111 1111 1111"); // ✅
z.creditCard().parse("4111 1111 1111 1112"); // ❌ bad checksum

z.properties()

z.property() 的多属性对应项。(#5912

const okResponse = z.instanceof(Response).check(
  ...z.properties({
    status: z.number().min(200).max(299),
    redirected: z.literal(false),
  })
);
 
okResponse.parse(new Response("ok")); // ✅
okResponse.parse(new Response("", { status: 404 })); // ❌ status

z.deepPartial()

在从 Zod 4 中移除为方法后,以函数形式回归。(#5928

const Post = z.object({
  title: z.string(),
  author: z.object({ name: z.string(), email: z.string() }),
});
 
const PartialPost = z.deepPartial(Post);
type PartialPost = z.output<typeof PartialPost>;
// => { title?: string; author?: { name?: string; email?: string } }
 
PartialPost.parse({ author: {} }); // ✅

结果仍然是一个 ZodObject,因此 .shape.extend() 仍可继续使用。

.exactPartial()

类似于 .partial(),但会将每个字段包装在 z.exactOptional() 而非 z.optional() 中:键可以省略,但显式的 undefined 会被拒绝。这与启用 exactOptionalPropertyTypes 时 TypeScript 的 Partial<> 相匹配。(#6065

const Recipe = z.object({ title: z.string(), servings: z.number() });
 
const PartialRecipe = Recipe.exactPartial();
PartialRecipe.parse({});                    // ✅
PartialRecipe.parse({ title: undefined });  // ❌

在 Zod Mini 中,它是一个顶层函数:z.exactPartial(Recipe)

z.validate()

独立的布尔值验证功能,适用于 Zod、Zod Mini 和 Zod Core。它无需构造 ZodError,即可回答“这个输入是否有效?”,因此拒绝输入的成本很低:对于无效输入,其速度最多比 .safeParse().success 快 16 倍。返回类型是针对 schema 输入类型的类型守卫,而 z.validateAsync() 则覆盖带有异步 refinement 的 schema。(#6471

z.validate(z.string(), "hi"); // true
z.validate(z.string(), 42);   // false

z.input() / z.output()

将 schema 投影到其输入端或输出端。适用于独立验证 codec 的两端。(#5928

const isoDate = z.codec(z.iso.datetime(), z.date(), {
  decode: (s) => new Date(s),
  encode: (d) => d.toISOString(),
});
 
const Event = z.object({ name: z.string(), at: isoDate });
 
z.input(Event).parse({ name: "launch", at: "2024-01-01T00:00:00Z" }); // ✅
z.output(Event).parse({ name: "launch", at: new Date() });            // ✅

对于不包含 codecs/pipes 的 schema,这是一个空操作。

z.toZod<T>()

用于定义一个与静态类型完全一致的 Zod schema,通常适用于手写或外部定义的类型。(#5913

type Player = { username: string; xp: number };
 
const Player = z.toZod<Player>()(
  z.object({
    username: z.string(),
    xp: z.number(),
  })
);
 
Player.shape.username; // ZodString — the schema is returned unchanged

z.getDiscriminatedOption()

根据 discriminator 值提取判别联合成员。(#5947

const Fruit = z.object({ type: z.literal("fruit"), seeds: z.boolean() });
const Veg = z.object({ type: z.literal("vegetable"), leafy: z.boolean() });
const Produce = z.discriminatedUnion("type", [Fruit, Veg]);
 
z.getDiscriminatedOption(Produce, "fruit"); // typeof Fruit
z.getDiscriminatedOption(Produce, "meat");  // ❌ TypeScript error

循环输入

Zod 的递归 schema 现在支持循环数据。出于 bundle 大小的考虑,Zod Mini 要求显式注册 memoizer。(#6387#6482

const Category = z.object({
  name: z.string(),
  get subcategories() {
    return z.array(Category);
  },
});
 
const input: any = { name: "root", subcategories: [] };
input.subcategories.push(input);
 
const result = Category.parse(input);
result.subcategories[0] === result; // true

Schema 内存占用减少 9 倍

在 Zod 4.4 中,一个简单的 z.string() 会保留 7.5kb 的堆内存。在 Zod 4.5 中,它只保留 784 字节。

在共享的千字节坐标轴上显示一个 schema 实例保留的堆内存,zod 4.4.3 为灰色条,4.5 的大小显示为其中的蓝色条:10 键对象从 82.0kb 降至 11.0kb(7.4 倍),联合类型从 17.5kb 降至 2.13kb(8.3 倍),z.string().min(1) 从 16.7kb 降至 3.37kb(5.0 倍),record 从 16.4kb 降至 2.64kb(6.2 倍),z.string().optional() 从 12.6kb 降至 1.50kb(8.4 倍),字符串数组从 11.2kb 降至 1.93kb(5.8 倍),z.string() 从 7.53kb 降至 784b(9.8 倍),z.number() 从 4.44kb 降至 706b(6.4 倍);相比 4.4.3 最多减少 9.8 倍内存
每个 schema 实例保留的堆内存,Zod 4.4.3 与 4.5 对比(基准测试

在 Zod 4.4 及更早版本中,所有 schema 方法都会自动绑定到实例自身。这使用户可以从 schema 中提取方法,而不会因 this 绑定造成问题。

const { parse } = z.string();
 
parse("some data");

这样做的一个后果是每个绑定的方法都会在堆上分配空间;正如预期的那样,方法实现不会通过 prototype 在所有实例之间共享。Zod 4.5 实现了一种方法 memoization 模式,避免在绑定方法实际被访问之前分配它们。

更快的失败处理

Zod 的 .parse().safeParse() 会实例化一个 JavaScript Error,该操作会捕获堆栈跟踪。在验证失败的情况下,这通常比解析逻辑本身慢得多。使用 .safeParse() 时,Zod 不再捕获此堆栈跟踪,使失败路径上的解析速度提升约 7.5 倍。(#6316#6450

const result = Player.safeParse({ username: 42, bio: "hello", xp: 12 });
result.success; // false — ~7.5x faster than Zod 4.4
失败的 safeParse 每次耗时柱状图:zod 4.4 为 6.3 微秒,zod 4.5 为 840 纳秒——快 7.6 倍
Player schema(基准测试

z.object() 中的 Symbol 键

现在 shape 可以声明 Symbol 键。TypeScript 会对其进行跟踪:const Symbol 会推断为 unique symbol,因此 z.infer 会将该键推断为必需键,并检查其值类型。未声明的 Symbol 键仍会被忽略。(#6448

const TAG = Symbol("tag");
const schema = z.object({ name: z.string(), [TAG]: z.number() });
 
schema.parse({ name: "alice", [TAG]: 42 }); // ✅ { name: "alice", [TAG]: 42 }
schema.safeParse({ name: "alice" });        // ❌ the symbol key is required

Bug 修复

以下修复都解决了 soundness 问题,因此依赖旧行为的 schema 现在可能会拒绝过去能够接受的输入。

⚠️ z.iso.datetime() 要求秒

RFC 3339 强制要求秒。z.iso.datetime()z.iso.datetime({ offset: true }) 不再接受类似 2020-01-01T06:15Z 的分钟精度输入。local: true 仍然接受 2020-01-01T06:15,因为无限定的 datetime 无论如何都不属于 RFC 3339。(#6457

z.iso.datetime().parse("2020-01-01T06:15:00Z"); // ✅
z.iso.datetime().parse("2020-01-01T06:15Z");    // ❌ was accepted in 4.4

要接受这两种形式,请联合两种精度:

z.union([z.iso.datetime(), z.iso.datetime({ precision: -1 })]);

⚠️ 字符串长度按 code point 计算

.min().max().length() 之前按 UTF-16 code unit 计数,因此 z.string().max(5) 会拒绝五个 emoji。现在它们按 Unicode code point 计数,这也是所有非 JS 消费者处理长度限制时采用的方式(Postgres、MySQL、Go、Python,以及 z.toJSONSchema() 生成的 maxLength)。对于 astral 输入,.max() 只会放宽限制;.min().length() 则会收紧限制。Grapheme 不变——一个 ZWJ 序列仍然包含多个 code point。(#6441

z.string().max(5).parse("😀😀😀😀😀"); // was too_big, now passes
z.string().min(5).parse("😀😀😀");     // was fine, now too_small

关闭 #3355

⚠️ Record 键和交集与 TypeScript 保持一致

Record 的键 schema 现在只约束与其匹配的键,就像 TypeScript 对待 index signature 的方式一样。将对象与 pattern-keyed record 求交集时,不再拒绝对象自身的键。(#6412

z.object({ name: z.string() })
  .and(z.record(z.string().regex(/^S_/), z.string()))
  .parse({ name: "a", S_a: "s" });
// 4.4: throws invalid_key on "name"
// 4.5: { name: "a", S_a: "s" }

另外,unrecognized_keys issue 不再中止产生它的 schema,因此带有额外键和错误值的严格对象现在会报告两个 issue,而不只是第一个。关闭 #2200#2573#4017#5663

⚠️ __proto__ 始终会被移除

对象和 record parser 现在会丢弃 __proto__ 键,无论它来自输入、由 schema 声明,还是由 record key transform 生成。被 record 的 key schema 规范化__proto__ 的键也会被丢弃。.strict() 会将自有的 __proto__ 输入键报告为 unrecognized_keys,而不是静默吞掉它。错误格式化器和两个 JSON Schema 转换器都会使用 own-property 写入,因此 toStringconstructor 路径片段无法遍历到 Object.prototype#6213#6367#6346)。(#6386#6354#6355#6221

⚠️ 更严格的字符串格式

  • z.ipv6() 之前通过将字符串传递给 new URL() 进行验证,这会让 ::@1\::1\n 通过。现在它会直接检查地址字符集(#6442)。
  • z.ulid() 将第一个字符限制为 07;任何更大的值都会导致 48 位时间戳溢出。现在,不以真实时间戳开头的 fixture(例如以字母开头的 fixture)会被拒绝(#6095)。
  • z.httpUrl() 强制执行 RFC 1035 对 host 的长度限制,与 z.hostname() 保持一致(#6035)。
  • z.emoji() 在匹配失败时不再发生指数级回溯(#6347)。
  • z.string().includes(sub, { position: N }) 会生成一个允许至少 N 个前导字符的 JSON Schema pattern,与 String.prototype.includes 保持一致(#6024)。

Commits

Zod 4.5 汇总了 155 个 commit。感谢所有贡献者:[@dokson](https://github.com/dokson)、[[@deepshekhardas](https://github.com/deepshekhardas)](https://github.com/deepshekhardas)、[[@zirkelc](https://github.com/zirkelc)](https://github.com/zirkelc)、[[@francisjohnjohnston-web](https://github.com/francisjohnjohnston-web)](https://github.com/francisjohnjohnston-web)、[[@MerlijnW70](https://github.com/MerlijnW70)](https://github.com/MerlijnW70)、[[@codinsonn](https://github.com/codinsonn)](https://github.com/codinsonn)、[[@oimo23](https://github.com/oimo23)](https://github.com/oimo23)、[[@JSap0914](https://github.com/JSap0914)](https://github.com/JSap0914)、[[@zelinewang](https://github.com/zelinewang)](https://github.com/zelinewang)、[[@abhishek-chaudhary2003](https://github.com/abhishek-chaudhary2003)](https://github.com/abhishek-chaudhary2003)、[[@spokodev](https://github.com/spokodev)](https://github.com/spokodev)、[[@Mohammad-Faiz-Cloud-Engineer](https://github.com/Mohammad-Faiz-Cloud-Engineer)](https://github.com/Mohammad-Faiz-Cloud-Engineer)、[[@hamed-bavar](https://github.com/hamed-bavar)](https://github.com/hamed-bavar)、[[@MGPOCKY](https://github.com/MGPOCKY)](https://github.com/MGPOCKY)、[[@ChiChuRita](https://github.com/ChiChuRita)](https://github.com/ChiChuRita)、[[@dinwwwh](https://github.com/dinwwwh)](https://github.com/dinwwwh)、[[@thristhart](https://github.com/thristhart)](https://github.com/thristhart)、[[@tsmartin9](https://github.com/tsmartin9)](https://github.com/tsmartin9)、[[@vedanshshetti](https://github.com/vedanshshetti)](https://github.com/vedanshshetti)、[[@belicam](https://github.com/belicam)](https://github.com/belicam)、[[@frastefanini](https://github.com/frastefanini)](https://github.com/frastefanini)、[[@andersk](https://github.com/andersk)](https://github.com/andersk)、[[@musaddiq-rafi](https://github.com/musaddiq-rafi)](https://github.com/musaddiq-rafi)、[[@tachmyratsaparmyradov](https://github.com/tachmyratsaparmyradov)](https://github.com/tachmyratsaparmyradov)、[[@arvindfroi](https://github.com/arvindfroi)](https://github.com/arvindfroi)、[[@KUMachine](https://github.com/KUMachine)](https://github.com/KUMachine)、[[@spidersouris](https://github.com/spidersouris)](https://github.com/spidersouris)、[[@catdalfonso](https://github.com/catdalfonso)](https://github.com/catdalfonso)、[[@mneetika](https://github.com/mneetika)](https://github.com/mneetika)、[[@gwagjiug](https://github.com/gwagjiug)](https://github.com/gwagjiug)、[[@MahinAnowar](https://github.com/MahinAnowar)](https://github.com/MahinAnowar)、[[@MaksZhukov](https://github.com/MaksZhukov)](https://github.com/MaksZhukov)、[[@emmayusufu](https://github.com/emmayusufu)](https://github.com/emmayusufu)、[[@agcty](https://github.com/agcty)](https://github.com/agcty)、[[@devareddy05](https://github.com/devareddy05)](https://github.com/devareddy05)、[[@Vish05](https://github.com/Vish05)](https://github.com/Vish05)、[[@yamcodes](https://github.com/yamcodes)](https://github.com/yamcodes)、[[@mattiasahlsen](https://github.com/mattiasahlsen)](https://github.com/mattiasahlsen)、[[@samchungy](https://github.com/samchungy)](https://github.com/samchungy)、[[@ozzyfromspace](https://github.com/ozzyfromspace)](https://github.com/ozzyfromspace)、[[@udohjeremiah](https://github.com/udohjeremiah)](https://github.com/udohjeremiah)、[[@patrickwehbe](https://github.com/patrickwehbe)](https://github.com/patrickwehbe)、[[@gajus](https://github.com/gajus)](https://github.com/gajus)、[[@Harm-Nullix](https://github.com/Harm-Nullix)](https://github.com/Harm-Nullix)、[[@thwbh](https://github.com/thwbh)](https://github.com/thwbh)、[[@IdanGonen](https://github.com/IdanGonen)](https://github.com/IdanGonen)、[[@irfanfandi](https://github.com/irfanfandi)](https://github.com/irfanfandi)、[[@JuerGenie](https://github.com/JuerGenie)](https://github.com/JuerGenie)、[[@marcalexiei](https://github.com/marcalexiei)](https://github.com/marcalexiei)、[[@itsahmedbilal](https://github.com/itsahmedbilal)](https://github.com/itsahmedbilal)、[[@DucMinhNe](https://github.com/DucMinhNe)](https://github.com/DucMinhNe)、[[@meliharik](https://github.com/meliharik)](https://github.com/meliharik)。