跳到主要内容

创建日期: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 字符串)
URLz.url()
邮箱 / UUIDz.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 本体是一个结构化 ZodErrorissues: [{ code, path, message, ... }]——codetoo_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 同时给类型 + 运行时校验(本文)
1MCP 与 Zod:协议只要 JSON Schema,TS SDK 为什么绕不开 zod
2类型推导剖析infer/input/output、哪些 API 会分叉、v3 vs v4 类型性能实测
3源码剖析_zod 内部结构、parse 管线、issues 累积、v3→v4 架构对照
4Mini / Standard Schema / JSON Schema / 自定义扩展zod/mini~standardtoJSONSchema()zod/compile 实测

关联

参考