开篇引导段:今天在 GitHub Trending 上看到一个有意思的项目:Zod,一个 TypeScript 优先的 schema 校验库,能在运行时校验数据的同时,借助静态类型推断为你提供完整的类型安全。
一、项目概述
Zod 的定位非常清晰——TypeScript-first schema validation with static type inference(TypeScript 优先、带静态类型推断的 schema 校验)。在 TypeScript 项目中,我们常常面临一个割裂:编译期有 interface/type 保证类型,但运行时拿到的外部数据(API 响应、表单输入、环境变量)却是不可信的。传统做法要么是手写一堆 typeof 判断,要么引入 io-ts/yup 等校验库,但往往要重复定义两次类型。
Zod 的核心价值在于:一份 schema,既是运行时的校验器,也是编译期的类型来源。你定义一次 schema,Zod 通过 z.infer 自动推导出对应的 TypeScript 类型,彻底消除「类型与校验两套定义」的维护负担。
核心特性:
- 零外部依赖,核心包 gzip 后仅约
2kb - TypeScript 与纯 JS 双支持,TS 项目享受类型推断,JS 项目同样可用
- 不可变 API:所有
refine、extend等方法都返回新实例,不修改原 schema - 内置 JSON Schema 转换,便于与 OpenAPI 等生态互通
- AOT(Ahead-Of-Time)编译优化,对热路径校验可带来数倍性能提升(详见技术原理部分)
- 可在 Node.js 与所有现代浏览器中运行
二、技术原理
2.1 Schema 即单一事实来源(Single Source of Truth)
Zod 的设计哲学是:schema 是数据与类型唯一的真相来源。通过类型推导,compile-time 类型与 runtime 校验严格保持一致。
import * as z from "zod";
const Player = z.object({
username: z.string(),
xp: z.number(),
});
// 从 schema 提取出静态类型
type Player = z.infer<typeof Player>;
// 由于 schema 限制,以下写法在编译期就会报错
const player: Player = { username: "billie", xp: 100 };
当 schema 中存在 transform 等会改变输入/输出类型的 API 时,输入类型与输出类型可能不一致,Zod 用 z.input 与 z.output(等价于 z.infer)分别提取:
const mySchema = z.string().transform((val) => val.length);
type MySchemaIn = z.input<typeof mySchema>; // => string
type MySchemaOut = z.output<typeof mySchema>; // => number
2.2 不可变 API 与链式组合
Zod 的每个修饰方法(refine、transform、extend、.optional() 等)都不会修改原 schema,而是返回一个全新的 schema 实例。这一设计让 schema 可以安全地在多处复用与组合,避免了「改一处影响全局」的副作用。
2.3 AOT 编译:热路径的 2.4x 性能加速
这是 Zod 近期版本中非常亮眼的特性。对校验频繁执行的热路径,调用 z.compile(schema) 会返回一个经过**提前编译(AOT)**的 schema 克隆:合法输入走编译后的快速路径,非法输入自动回退到常规解析器,错误信息与常规解析完全一致。
在一个覆盖 55 个 schema 的基准测试中,中位数加速 2.4 倍,且随 schema 单次解析工作量增加而放大:
- 大型对象数组:约 9x
- 20 字段对象:约 9x
- 嵌套对象:约 4.5x
- 单纯的
z.string():几乎无收益(因为单条typeof本身已无可优化的派发与分配开销)
const CompiledPlayer = z.compile(Player);
CompiledPlayer.parse({ username: "billie", xp: 100 });
也可以全局开启(对 import 之后构造的所有 schema 生效):
import "zod/compile"; // 放在定义 schema 的模块之前
实现要点:
- 编译借助
new Function生成快速路径。当设置z.config({ jitless: true })(例如 CSP 受限环境)时全局模式会自动禁用;而显式调用z.compile()则是明确的 opt-in。 - 含
asyncrefine/transform 或少数其他结构的 schema 无法编译,z.compile()会原样返回该 schema 并继续使用常规解析器;传{ strict: true }则会抛出ZodCompileAsyncError/ZodCompileUnsupportedError。 - 非法输入下,refine/transform 可能执行两次(先快速路径,再回退路径)。
- 从已编译 schema 派生新 schema(
.refine()、.extend()等)返回的是未编译 schema,记得对最终 schema 再编译一次。
三、安装与快速开始
环境要求:Node.js 或任意现代浏览器,无需额外运行时依赖。
npm install zod
最简示例——定义 schema 并解析数据:
import * as z from "zod";
const User = z.object({
name: z.string(),
});
// 不可信数据
const input = { name: "Ada" };
// 解析结果为经过校验、类型安全的深拷贝
const data = User.parse(input);
console.log(data.name); // 类型安全,可放心使用
parse 在合法时返回强类型深拷贝的输入;不合法时抛错(见下文错误处理)。
四、使用方法与实战
4.1 解析与错误处理
默认 .parse() 在校验失败时抛出 ZodError,包含每个问题的细粒度信息:
try {
Player.parse({ username: 42, xp: "100" });
} catch (err) {
if (err instanceof z.ZodError) {
err.issues;
// [
// { expected: 'string', code: 'invalid_type', path: ['username'], message: 'Invalid input: expected string' },
// { expected: 'number', code: 'invalid_type', path: ['xp'], message: 'Invalid input: expected number' }
// ]
}
}
为避免 try/catch,可使用 .safeParse() 返回结果对象,其类型是 discriminated union,便于分支处理:
const result = Player.safeParse({ username: 42, xp: "100" });
if (!result.success) {
result.error; // ZodError 实例
} else {
result.data; // { username: string; xp: number }
}
注意:当 schema 使用了
asyncrefine/transform,需改用.parseAsync()/.safeParseAsync()。
4.2 进阶:字面量与转换组合
Zod 可以组合多种校验与转换。例如处理表单中「空字符串表示无日期」的场景:
import { z } from "zod";
const formDate = z.iso
.datetime({ offset: true })
.or(z.literal(""))
.transform((v) => (v === "" ? null : v));
console.log("empty:", formDate.safeParse("")); // null
console.log("valid:", formDate.safeParse("2024-01-15T10:30:00.000Z"));
console.log("invalid:", formDate.safeParse("not-a-date")); // 报错
4.3 实际项目示例
典型用法是定义一个贯穿前后端的共享 schema,前端用它做表单校验,后端用它校验请求体,并通过 z.infer 让两端共享同一份类型定义,从根本上杜绝「前端类型与后端契约漂移」。
const CreateUserSchema = z.object({
username: z.string().min(3),
xp: z.number().int().nonnegative(),
});
type CreateUser = z.infer<typeof CreateUserSchema>;
// 后端校验请求体
app.post("/users", (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error);
// parsed.data 已是类型安全的 CreateUser
});
五、常见问题与解决方案
Q1:parse 抛错,但不想写 try/catch?
使用 .safeParse()(同步)或 .safeParseAsync()(含异步 refine/transform),通过返回结果的 success 分支处理,避免异常捕获样板代码。
Q2:遇到 async refine/transform,报错说找不到方法?
异步场景必须调用 .parseAsync() / .safeParseAsync(),.parse() 不支持异步逻辑。
Q3:开启了 import "zod/compile" 全局编译但无效?
检查是否处于 CSP 受限环境(如某些严格的前端沙箱)。此时 new Function 被禁用,需设置 z.config({ jitless: true }) 关闭 JIT 编译,或仅对可信 schema 显式调用 z.compile()。含异步 refine/transform 的 schema 无法编译,会被原样回退到常规解析器。
Q4:编译后性能没提升?
AOT 编译的收益与 schema 单次解析的工作量正相关。像 z.string() 这种单节点校验几乎无收益;对象字段越多、数组越大,加速越明显(实测大数组/多字段对象约 9x)。此外,从已编译 schema 派生的新 schema 是未编译状态,记得对最终 schema 再调一次 z.compile()。
Q5:在浏览器/Node 都能用吗?需要额外依赖吗? Zod 零外部依赖,同时支持 Node.js 与所有现代浏览器,gzip 后核心仅约 2kb。
六、总结
Zod 用「一份 schema 同时驱动运行时校验与编译期类型」的极简理念,解决了 TS 项目中类型与校验长期割裂的痛点。不可变 API 带来安全的组合能力,而新引入的 AOT 编译进一步把热路径校验性能推向 2.4x~9x 的提升区间。无论是表单校验、API 契约还是环境变量解析,Zod 都已成为 TypeScript 全栈类型安全的事实标准基石。如果你还没在项目中用过它,现在正是把它纳入工具箱的好时机。
项目地址:https://github.com/colinhacks/zod | 官方文档:https://zod.dev/api