本节目标:读完这一节,你能用 Zod 声明一份 schema,并用
z.infer从它自动推导出 TypeScript 类型,让「校验规则」与「类型定义」只维护一份;能区分parse与safeParse的使用场景,读懂ZodError.issues的结构;能用optional、default、transform、refine处理真实业务里的边界情况;能写出判别联合与递归 schema;并且知道z.input与z.output为什么可能不同。
13.2 Zod 模式验证与类型推导
上一节我们把问题摆清楚了:类型在运行时被擦除,as 断言只是承诺,边界数据必须真正检查一遍。
问题是,检查写成什么样?最朴素的做法是手写判断:
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.name === "string";
}
这段代码有三个问题:写起来啰嗦、嵌套对象会迅速膨胀、而且它和 interface User 是两份独立维护的定义。后端加一个字段,你得改两处;改漏一处,类型和校验就漂移了。
我们需要的是「一份定义,两处生效」:既能被运行时执行,又能被编译器推导。这正是 schema 校验库要做的事,而 Zod 是当前 TypeScript 生态里最主流的选择。
从 schema 到类型:一个定义,两个用途
本书的 Zod 示例使用 zod@3.25.76(npm install --save-exact zod@3.25.76),以复现 flatten() 与下文错误结构。Zod 4 迁移指南
涉及 API 与错误格式变化,升级时需核对,不能直接混用两版输出。先看完整形态:
import { z } from "zod";
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(50),
email: z.string().email(),
age: z.number().int().min(0).optional(),
role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
tags: z.array(z.string()).default([]),
});
// 从 schema 推导类型 —— 注意 typeof
type User = z.infer<typeof UserSchema>;
User 等价于手写的 { id: number; name: string; email: string; age?: number; role: "admin" | "editor" | "viewer"; tags: string[] }——编辑器悬停可以看到完全一致的结构。
几个细节值得留意:
z.enum([...])推导出的是字面量联合,不是string。这和 7.1 联合类型与字面量类型 讲过的能力对上了。z.number().int().positive()推导出的仍然是number。Zod 的校验规则不改变类型,它只影响运行时是否放行。.default("viewer")让role在输出里是必填的,而在输入里是可选的。这个不对称是理解z.input/z.output的钥匙。
parse 与 safeParse:抛错还是返回结果
// 方式一:失败抛 ZodError
const user = UserSchema.parse(raw);
// 方式二:失败返回结果对象,不抛错
const result = UserSchema.safeParse(raw);
if (result.success) {
console.log(result.data.name); // 此处 result.data 是 User
} else {
console.log(result.error.issues);
}
safeParse 的返回值是一个判别联合(7.2 判别联合
的实战应用):{ success: true; data: Output } | { success: false; error: z.ZodError }。
因为判别字段是 success,if (result.success) 之后编译器会自动收窄到成功分支,result.data 直接就是 User 类型——不需要任何断言。这是 schema 库最优雅的地方:校验结果本身携带了类型信息。
选择建议:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 脚本、CLI、启动期配置 | parse | 失败就应该立刻崩溃 |
| HTTP 请求体、表单 | safeParse | 需要把错误转成 400 响应 |
| 上游 API 响应 | safeParse | 需要记录问题并降级处理 |
| 测试断言 | parse | 失败即测试失败,信息足够 |
读懂 ZodError.issues
校验失败的细节全在 error.issues 里。给一段真实输入:
const bad = { id: -1, name: "", email: "not-an-email" };
const result = UserSchema.safeParse(bad);
if (!result.success) {
console.log(JSON.stringify(result.error.issues, null, 2));
}
输出(节选两个):
[
{
"code": "too_small",
"minimum": 1,
"type": "number",
"inclusive": false,
"message": "Number must be greater than 0",
"path": ["id"]
},
{
"validation": "email",
"code": "invalid_string",
"message": "Invalid email",
"path": ["email"]
}
]
每个 issue 都有:
code:机器可读的错误类别,适合做统计与国际化映射;path:出错字段的路径数组,嵌套对象会是["address", "city"];message:默认英文提示,可以被.min(1, "名字不能为空")这类第二参数覆盖。
给表单用时,通常要压成「字段 → 消息数组」的形状:
const flat = result.error.flatten();
// {
// formErrors: [],
// fieldErrors: {
// id: ["Number must be greater than 0"],
// name: ["String must contain at least 1 character(s)"],
// email: ["Invalid email"]
// }
// }
formErrors 收集的是「不属于任何具体字段」的错误,比如整个对象级别的 refine 失败。
常用修饰符速查
| API | 作用 | 对推导类型的影响 |
|---|---|---|
.optional() | 允许 undefined | T | undefined |
.nullable() | 允许 null | T | null |
.nullish() | 允许 null 或 undefined | T | null | undefined |
.default(v) | 缺失时填默认值 | 输入可选,输出必填 |
.catch(v) | 任何失败都回退到 v | 永不失败,输出必为 T |
.transform(fn) | 校验通过后转换 | 输出类型由 fn 决定 |
.refine(fn, msg) | 自定义布尔校验 | 类型不变 |
.superRefine(fn) | 多字段联动、可加多个 issue | 类型不变 |
.brand<"UserId">() | 打上名义类型标记 | 变成不可与 number 互换的类型 |
.coerce.number() | 先转成数字再校验 | 输入是 unknown |
.brand() 值得一提:它把 number 变成名义类型,让 UserId 和 OrderId 不再互相赋值,弥补结构化类型的短板。这个技巧的完整讨论在 5.3 结构化类型与两者取舍
。
.refine:跨字段规则
单字段规则用链式 API 就够了,跨字段的必须用 refine:
const SignupSchema = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "两次输入的密码不一致",
path: ["confirm"], // 把错误挂到 confirm 字段上
});
superRefine 是它的加强版,可以在一次回调里通过 ctx.addIssue({ code: z.ZodIssueCode.custom, message, path }) 追加多个问题——适合「开始时间不能晚于结束时间」这类需要同时报告多条冲突的规则。注意 addIssue 必须带 code,custom 是最通用的取值。
.transform:校验之后再加工
transform 让 schema 承担一部分「解析」职责,而不只是「放行」:
const DateSchema = z
.string()
.datetime()
.transform((s) => new Date(s));
type Parsed = z.infer<typeof DateSchema>; // Date
这正好解决了 13.1 里 createdAt: Date 那个坑:JSON 里是字符串,解析后变成真正的 Date 对象。
z.input 与 z.output:为什么它们是两个类型
一旦用了 transform 或 default,schema 就有了两个不同的类型:进来时和出去时。
const Schema = z.object({
name: z.string().trim(),
age: z.coerce.number().default(0),
createdAt: z.string().transform((s) => new Date(s)),
});
type In = z.input<typeof Schema>;
// { name: string; age?: unknown; createdAt: string }
type Out = z.output<typeof Schema>;
// { name: string; age: number; createdAt: Date }
z.infer<T>是z.output<T>的别名——这是最容易记混的一点。- 函数签名里,参数位置应该用
z.input,返回值位置应该用z.output:
function createUser(payload: z.input<typeof Schema>): z.output<typeof Schema> {
return Schema.parse(payload);
}
如果参数写成了 z.output,调用方就必须自己提前转换,schema 的价值就丢了一半。
判别联合:z.discriminatedUnion
手写判别联合的校验很麻烦,Zod 提供了专门的原语:
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string().min(1) }),
]);
type AppEvent = z.infer<typeof EventSchema>;
// { type: "click"; x: number; y: number }
// | { type: "keypress"; key: string }
function handle(e: AppEvent) {
switch (e.type) {
case "click":
return e.x + e.y; // 收窄到 click 分支
case "keypress":
return e.key.toUpperCase(); // 收窄到 keypress 分支
}
}
相比 z.union,discriminatedUnion 会先读判别字段再选分支,错误信息更精确(不会把两个分支的错误都堆给你),性能也更好。第 7 章的判别联合在这里完成了从「类型技巧」到「运行时保障」的闭环。
递归 schema:z.lazy
树形结构需要递归定义。类型可以自引用,但 schema 是值,直接自引用会触发「变量在初始化前使用」:
// ❌ Block-scoped variable 'CategorySchema' used before its declaration
const CategorySchema = z.object({ name: z.string(), children: z.array(CategorySchema) });
用 z.lazy 把求值推迟到真正解析的时候:
interface Category {
name: string;
children: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string().min(1),
children: z.array(CategorySchema),
})
);
这里显式标注了 z.ZodType<Category>,因为递归推导会让编译器无从下手。代价是类型来自你手写的 interface,而不是从 schema 推导——递归场景下必须接受这一点,也意味着这个 interface 需要和 schema 同步维护。
未知字段策略:strip / passthrough / strict
后端多加一个字段时,默认行为是什么?Zod 默认 strip(剥掉未知字段):
const UserSchema = z.object({ id: z.number(), name: z.string() });
UserSchema.parse({ id: 1, name: "Ada", isAdmin: true });
// → { id: 1, name: "Ada" } isAdmin 被丢弃
三种策略对比:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| 默认(strip) | 丢弃未知字段 | 解析上游响应,防止脏数据扩散 |
.passthrough() | 原样保留 | 需要透传未知字段(如代理层) |
.strict() | 未知字段报错 | 契约测试、本地配置,抓拼写错误 |
一个实用组合:生产环境用 strip,测试环境用 strict。生产环境需要容忍上游演进,测试环境需要尽早发现拼写错误与契约漂移。
常见坑与报错
坑一:z.infer 忘了 typeof。
type A = z.infer<typeof UserSchema>; // ✅ 对
type B = z.infer<UserSchema>; // ❌ UserSchema 是值,不是类型
报错信息:
'UserSchema' refers to a value, but is being used as a type here.
Did you mean 'typeof UserSchema'? ts(2749)
坑二:schema 与手写 interface 双写。 定义了 interface User 又写了 UserSchema,然后 parse 的结果 as User。这就是 13.1 的盲区原地复活。正确做法是只留 schema,类型用 z.infer 推导。
坑三:把 .optional() 与 .default() 混用。
z.string().optional().default("x"); // 输入 string | undefined,输出 string
z.string().default("x").optional(); // 输出变成 string | undefined
链式顺序会影响结果,写反了会让下游多出一层 undefined 判断。
坑四:以为 schema 能替代业务校验。 z.string().min(1) 只保证非空字符串;「这个用户名是否已被占用」需要查数据库,属于业务逻辑,应该放在 schema 之后。
与手写类型守卫的对比
回到本节开头那个 isUser 函数,现在可以给出结论了:
| 维度 | 手写类型守卫 | Zod schema |
|---|---|---|
| 类型推导 | 需要手写 value is User | z.infer 自动 |
| 定义份数 | 2 份(interface + 守卫) | 1 份 |
| 错误信息 | 只有 true/false | 带 path 与 code 的结构化 issues |
| 嵌套对象 | 手写递归,极易出错 | 自动组合 |
| 转换能力 | 无 | transform / coerce |
| 运行时开销 | 最小 | 有成本,通常可接受 |
| 包体积 | 0 | 增加依赖 |
对于零依赖、极简的检查(比如判断 typeof x === "string"),类型守卫仍然合适——7.3 类型守卫与控制流分析
里的技巧在日常开发中依然有用。但只要涉及对象结构、嵌套、错误提示,schema 就是更划算的选择。
延伸阅读:Zod 在表单场景下的完整实践,可以参考既有专题 /frontend-forms-validation-architecture/ 与 /typescript-zod-validation/ ;如果需要更细的性能取舍与替代方案对比,可以看 /typescript-runtime-validation-typesafe/ 。
小结
- schema 的价值是「一份定义,两处生效」:运行时由
parse/safeParse执行,编译期由z.infer推导,从根上消除类型与校验规则漂移。 safeParse返回判别联合,if (result.success)之后编译器自动收窄,无需断言;失败细节在error.issues,可用flatten()压成表单友好的形状。optional/nullable/default/catch/transform/refine覆盖了绝大多数业务边界;default与transform会让输入类型与输出类型分离,因此有了z.input与z.output(z.infer是z.output的别名)。- 判别联合用
z.discriminatedUnion,递归结构用z.lazy并显式标注z.ZodType<T>。 - 未知字段默认 strip;生产用 strip 容错、测试用 strict 抓错,是稳妥的组合。
- 常见坑集中在:忘写
typeof、与手写interface双写、链式顺序颠倒、把 schema 当成业务校验的替代品。
有了 Zod,我们手里就有了一把能同时满足编译期和运行时的尺子。下一节要解决的是「尺子量在哪里」——把校验准确地放到进程的每一个入口:环境变量、HTTP 请求体、上游响应、消息队列,并处理契约演进与错误上报。
阅读导航:上一节:13.1 类型擦除带来的运行时盲区 · 下一节:13.3 API 契约与边界数据校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。