创建日期:2026-09-07 | 最近更新:2026-09-15 事实核对基于 zod 4.5.4(2026-08-29 发布于 npm)在本机 Node 24 的实测;文中所有输出均为真实运行结果。
Zod 入门:一份 schema,类型与运行时校验都有了
一句话:Zod 是 TypeScript 生态最流行的 schema 校验库——你用它「声明一次数据的形状」,它既能当编译期类型(自动推导 TS 类型),又能在运行时校验来路不明的数据(接口响应、表单、环境变量、配置文件)。
纯 TS 项目里几乎不用写两遍(一个手写
interface+ 一套手写if校验)。schema 就是唯一事实来源。
本文定位
Zod 用法文档官方已经写得很全,这里不重复抄手册。本文按「探索笔记」的路子,讲清楚:它解决什么问题、心智模型是什么、核心 API 一张表记住、真正的坑在哪,并附上我本机实测输出。作为 code 栏目新开的子项目,这篇是 0 号入门篇。
顺带串起本站其它栏目:同属「TypeScript 优先的 schema 派」还有 pi 系列里 TypeBox(pi-ai 的工具参数 schema)——TypeBox 主打 JSON-schema 互操作,Zod 主打 TS 类型推导;而 InkOS 的书状态
state/*.json正是用 Zod 做校验的。
1. 它解决什么问题
TS 的类型(interface / type)只活在编译期:运行时代码拿到的数据不保证符合声明——尤其当它来自:
fetch()回来的 JSON(你相信后端,但后端不是你);- 表单/命令行/URL query(用户输入永远可疑);
process.env(全是string | undefined,配错静默);- 另一个进程/微服务/旧接口(字段说没就没)。
于是每个接口都要手写一串 if (typeof x !== 'string') throw ...。Zod 的做法:写一个运行时对象(schema),它会校验;再让 TS 从同一个对象推导类型——声明一次,两头用:
import { z } from 'zod';
const User = z.object({
id: z.number(),
name: z.string(),
});
// 用法:给一个"可能是 User"的东西,得到"确实是 User"
const user = User.parse(unknownData);
// ^? User 类型由 schema 自动推导
不用 z.infer 也能拿到类型:typeof User 的返回类型就是 TS 类型。需要显式命名时再 type User = z.infer<typeof User>。
2. 安装
npm i zod
- 零依赖;Node / 浏览器 / Bun / Deno 通用;
- 配 TS 时保持
strict即可,schema 推导出来的类型会继承你的严格性; - 本文以下示例基于 zod 4.5.4(2026-08-29)。网上大量 v3 教程仍大体适用,差异见 §10。
3. 核心心智模型:三种读法
一个 schema = 一个会校验的运行时对象。拿到数据后有三种读法,按「会不会炸」从强到弱:
User.parse(data) // 校验失败就 throw(适合"此处必须合法":启动时读配置)
User.safeParse(data) // 永不 throw:返回 { success, data | error }(适合大部分场景)
await User.parseAsync / safeParseAsync // 校验逻辑里可能有异步 refine
safeParse 的返回值在 v4 里是可辨别的联合类型,TS 能自动收窄:
const r = User.safeParse(unknownData);
if (r.success) {
r.data; // TS 知道这里只有 data
} else {
r.error; // TS 知道这里只有 ZodError
}
4. 基础类型:一张表记住最常用的
| 你想要的 | 写法 |
|---|---|
| 字符串 | z.string() |
| 数字 | z.number(),加修饰 .int() .positive() .nonnegative() .min(x) .max(x) |
| 布尔 | z.boolean() |
| 可空 / 可缺省 | z.string().nullable() / .optional()(或 nullish() 两都要) |
| 数组 | z.array(z.string()),z.array(z.string()).min(1) 等 |
| 字典 | z.record(z.string(), z.number()) |
| 字面量 | z.literal('click') |
| 枚举 | z.enum(['admin', 'user']) |
| 对象 | z.object({...})(默认剥离未知键,只留声明过的) |
| 日期 | z.date()(必须是 Date 对象)、z.iso.datetime()(接受 ISO 字符串) |
| URL | z.url() |
| 邮箱 / UUID | z.email() / z.uuid() |
| 任意 | z.unknown() / z.any()(从「边界数据」逐层收紧时先用它) |
对象默认行为要记牢:parse 一个带多余字段的对象,多余字段会被剥掉(不是报错)。要「多余键就报错 / 保留」,用
User.strict()/User.passthrough()。
实测:一个用户对象 schema
const User = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
email: z.email(),
tags: z.array(z.string()).default([]), // 缺省就给 []
});
User.safeParse({ id: 1, name: 'Lin', email: 'a@b.com' });
// → { success: true, data: { id: 1, name: 'Lin', email: 'a@b.com', tags: [] } }
const bad = User.safeParse({ id: -1, name: '', email: 'nope' });
// → { success: false, error: ZodError }
把错误打给人看,v4 新增的 z.prettifyError() 最省事。实测输出:
✖ Too small: expected number to be >0
→ at id
✖ Too small: expected string to have >=1 characters
→ at name
✖ Invalid email address
→ at email
而 bad.error 本体是一个结构化 ZodError:issues: [{ code, path, message, ... }]——code(too_small / invalid_format…)给机器做分支,path(如 ['email'])给表单标红,message 给人看。要逐条而不是抛异常,用 safeParse + 遍历 issues。
5. 组合:enum / 可辨识联合
const Role = z.enum(['admin', 'user']); // Role.parse('boss') → 抛错
const Event = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number() }),
z.object({ type: z.literal('view') }),
]);
// { type: 'click', x: 1 } → ok;{ type: 'hover' } → 不认这个 type,报错
discriminatedUnion(靠一个 type 字段区分)比普通 z.union([...]) 好用得多:报错能直接告诉你是哪个分支没匹配上,TS 收窄也准。跟 TS 原生的 可辨识联合 + switch 是一一对应的——schema 只是把 union 搬到了运行时。
6. 变换与自定义校验:transform / refine
parse 不仅能「验」,还能「改」——数据过一道 schema 后直接变成你要的形状:
// 收尾处理:trim + 小写
const Slug = z.string().transform((s) => s.trim().toLowerCase());
Slug.parse(' Hello World '); // → 'hello world'
// 字符串转数字
const Port = z.coerce.number().int().min(1).max(65535); // parse('3300') → 3300
跨字段的规则(两次密码一致、起始日期早于结束日期)用 .refine(),它能看到整个对象:
const Signup = z.object({
password: z.string().min(6),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: '两次密码不一致',
path: ['confirm'], // 把错误挂到 confirm 字段,方便表单标红
});
Signup.safeParse({ password: '123456', confirm: '654321' }).error.issues[0].path;
// → ['confirm']
一条 .refine() 不够用(要一次产出多条错误 / 异步校验)时,上 .superRefine((val, ctx) => { ctx.addIssue({...}); })。
7. 类型推导:input / output 分开了
z.infer<typeof Schema> 在 v4 里默认是输出类型(transform 之后的样子);输入类型(parse 前应该长什么样)用 z.input:
const Slug = z.string().transform(s => s.trim().toLowerCase());
type In = z.input<typeof Slug>; // string(允许带空格/大写)
type Out = z.output<typeof Slug>; // string(已小写)
有了 transform 后 input/output 会分叉——大部分场景你只关心 z.output。写函数时「参数用 z.input 声明的原始形状,返回值用 output」,就天然安全。
8. 实测一个像样的场景:环境变量 + API 响应
把上面串起来,是两个最常见的落点(本机跑通):
import { z } from 'zod';
// ① 进程启动就校验配置:配错立刻炸,而不是运行到一半才炸
process.env.MY_DB_URL = 'postgres://user:pw@localhost:5432/blog';
process.env.MY_PORT = '3300';
const Env = z.object({
MY_DB_URL: z.url({ message: 'MY_DB_URL 必须是合法 URL' }),
MY_PORT: z.coerce.number().int().min(1).max(65535).default(3300),
MY_DEBUG: z.enum(['0', '1']).default('0'),
});
const env = Env.safeParse(process.env);
// ② 校验来路不明的接口响应
const rawResponse = await fetch('/api/user').then(r => r.json()); // 真实场景
const User = z.object({
id: z.string().regex(/^u_/),
name: z.string().min(1),
age: z.number().int().nonnegative(),
email: z.email(),
tags: z.array(z.string()).max(10).default([]),
createdAt: z.iso.datetime(), // ISO 8601 字符串
});
const parsed = User.safeParse(rawResponse);
if (!parsed.success) {
console.log(z.prettifyError(parsed.error)); // 挂日志 / 上报,别让坏数据往下走
}
实测输出:
① env 校验: {"db":"postgres://user:pw@localhost:5432/blog","port":3300,"debug":"0"}
② API 校验: {"id":"u_123","name":"林","age":30,"email":"lin@example.com","tags":["ts","agent"],"createdAt":"2026-09-01T00:00:00Z"}
边界守则:把 safeParse 放在「数据进入你系统的那道门」(fetch 之后、读 env 之后、表单提交 handler 第一行)。一旦过了门,后面所有代码都能信任 data,不再需要散落的 if (x?.foo) 防御。这也是它跟 InkOS 状态 JSON 校验同一个道理:坏数据在门口被拒,不会滚雪球。
9. 真实坑(都实测过)
z.coerce.boolean()会把"false"解析成true。因为它是拿 JS 的Boolean("false")转的——任何非空字符串都是true。要从'0'/'1'/'true'/'false'字符串收布尔,别用 coerce.boolean,用z.enum(['true','false']).transform(v => v === 'true')这类显式映射。- 对象默认剥离未知键:后端多塞一个字段会静默丢,可能掩盖「字段名拼错」的 bug。想严一点用
.strict();想宽松透传用.passthrough()。 - 默认错误消息是英文工程风(
Too small: expected number to be >0)。要面向用户的文案:一是像上面那样给关键字段写{ message: '…' },二是把ZodError.issues自己转成中文/按表单字段映射,别依赖内置 message。 z.coerce.number()只解决「字符串数字」:'3300'→ 3300,但'33.5abc'、null照样报错——它是「尝试转」,不是「宽容一切」。- v3 老代码基本能跑:
z.string().email()、单参数z.record(z.string())在 v4 仍然可用(实测通过),v4 是新增了顶层快捷方式(z.email()等)和z.iso.datetime()这类便捷 API,不是推倒重来。
10. 从 v3 看 v4(帮你看老教程)
| v3 常见写法 | v4 情况(实测) |
|---|---|
z.string().email() | 仍可用;v4 新增顶层 z.email() 更短 |
z.string().uuid() | 同上 → z.uuid() |
z.record(z.string())(单参) | 仍可用;双参 z.record(key, value) 显式支持 key 校验 |
| 校验错误文案 | v4 更工程化的英文消息 + z.prettifyError() 人性化输出 |
safeParse 返回 | v4 为可辨识联合,r.data / r.error 可被 TS 收窄 |
更多迁移细节以官方迁移文档为准(见参考)。
11. 什么时候不用它
- 只求「表单级提示」而数据模型不复杂 → 原生 HTML 校验 + 少量手写即可;
- schema 要跨语言/进 JSON 互操作、或要喂给 AI 工具调用 → 看 TypeBox / JSON Schema(参考本站 pi-ai:工具参数 schema 用 TypeBox,正因为它的 schema 能序列化成 JSON);
- 只需异步 debounce 校验而不在意类型 → 本站已有 async-validator。
Zod 的甜点区是:纯 TS 项目里,「类型」和「运行时校验」想要一份声明搞定——它因此成了 tRPC、React Hook Form 的 resolver、Astro 内容集合、各类配置加载器的事实标配。
系列导航
| 篇 | 主题 |
|---|---|
| 0 | 入门:一份 schema 同时给类型 + 运行时校验(本文) |
| 1 | MCP 与 Zod:协议只要 JSON Schema,TS SDK 为什么绕不开 zod |
| 2 | 类型推导剖析:infer/input/output、哪些 API 会分叉、v3 vs v4 类型性能实测 |
| 3 | 源码剖析:_zod 内部结构、parse 管线、issues 累积、v3→v4 架构对照 |
| 4 | Mini / Standard Schema / JSON Schema / 自定义扩展:zod/mini、~standard、toJSONSchema()、zod/compile 实测 |
关联
- 同一码列更多 schema/校验类子项目:async-validator
- 使用 Zod 校验状态的邻站笔记:InkOS 入门
参考
- 官网文档:zod.dev(含 v3→v4 迁移指南)
- npm:npmjs.com/package/zod
- GitHub:github.com/colinhacks/zod
- 许可:MIT
- 本文事实基于 zod 4.5.4 核对,示例在 Node 24 实测;版本演进后以官方文档为准。