开篇引导段:今天在 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:所有 refineextend 等方法都返回新实例,不修改原 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.inputz.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 的每个修饰方法(refinetransformextend.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。
  • async refine/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 使用了 async refine/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