Zod 运行时校验与前后端类型安全方案
面试向讲清 Zod:为什么 TS 类型挡不住脏数据、一份 schema 如何同时产出运行时校验器和静态类型、 parse 与 safeParse 的选型、refine / transform / discriminatedUnion 实战、Zod 4 的破坏性变更与迁移要点, 以及环境变量、接口响应、表单三处边界的落地方案。
一句话概括
TypeScript 的类型在编译成 JS 的那一刻就被擦掉了,它对”接口实际返回了什么”零保护。 Zod 解决的就是这个洞:写一份 schema,同时得到两样东西——
- 运行时校验器(
.parse()/.safeParse()) - 静态 TS 类型(
z.infer<typeof Schema>)
面试里它通常不是单独问的,而是顺着上一句”类型只在编译期有效”追问:“那你怎么保证外部数据是对的?” 答”用断言”是减分项,答”在边界处用 schema 校验”才是正解。
核心知识点
1. 先看问题:断言是”承诺”,不是”验证”
1
2
3
4
5
6
7
8
// ❌ as 是你对编译器的一句承诺,运行时根本没人兑现
const user = (await res.json()) as User;
user.profile.name;
// 后端哪天把 profile 改成 null,线上直接白屏,本地开发环境全是好的
// ✅ 先校验再使用:不合规就当场失败,错误还能定位到字段
const user = UserSchema.parse(await res.json());
user.profile.name; // 能走到这一行,说明数据一定符合 schema
面试一句话:断言是”绕过检查”,校验是”执行检查”,两者不是二选一的关系,是完全不同的两件事。
2. 三步走:定义 schema → 推导类型 → 在边界 parse
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import * as z from 'zod';
// ① 定义 schema(Zod 4 写法:格式校验器提到顶层)
const UserSchema = z.object({
id: z.uuid(),
name: z.string().min(1).max(100),
email: z.email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.iso.datetime(),
});
// ② 类型从 schema 反推,不用手写第二遍——schema 改了类型自动跟着改
type User = z.infer<typeof UserSchema>;
// ③ 在边界处校验
const result = UserSchema.safeParse(rawData);
if (!result.success) {
console.log(result.error.issues); // [{ path: ['email'], message: 'Invalid email' }]
} else {
console.log(result.data.email); // ✅ 完全类型安全
}
注意 z.email() 而不是 z.string().email()——这是 Zod 4 的推荐写法:
1
2
3
4
5
// ❌ Zod 3 的链式写法:还能跑,但已废弃,而且会把整套字符串格式逻辑拖进 bundle
const Old = z.object({ email: z.string().email(), id: z.string().uuid() });
// ✅ Zod 4:格式校验器提到顶层,可 tree-shake,只用 email 就不会打包 uuid 的校验逻辑
const New = z.object({ email: z.email(), id: z.uuid() });
面试问”Zod 4 和 Zod 3 有什么区别”,答出这一条就稳了(完整清单见第 6 节)。
3. parse vs safeParse:按”失败是不是正常情况”来选
| 方法 | 失败时 | 适用场景 |
|---|---|---|
.parse(data) | 抛 ZodError | 环境变量、配置文件——失败就该让进程起不来 |
.safeParse(data) | 返回 { success: false, error } | 用户输入、接口响应、表单——失败是正常情况,要给用户看提示 |
.safeParseAsync(data) | 同上,返回 Promise | schema 里含异步 refine / transform 时必须用它 |
第三个是硬坑:schema 里只要有一个 async 校验,同步的 .parse() / .safeParse() 会直接抛异常(实测报错就是这句:Encountered Promise during synchronous parse. Use .parseAsync() instead.),不是返回 success: false,是整个调用炸掉。
4. 常用组合拳:可选、默认值、判别联合、跨字段校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 可选 / 可空 / 两者皆可
z.string().optional(); // string | undefined
z.string().nullable(); // string | null
z.string().nullish(); // string | null | undefined
// 默认值 + 强转:环境变量这种"全是字符串"的场景特别好使
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000), // "3000" -> 3000
NODE_ENV: z.enum(['dev', 'prod']).default('dev'),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env); // 缺字段/格式错,启动就崩,别等线上
// 判别联合:按 type 字段分叉,比 z.union 快,错误信息也更准
const Shape = z.discriminatedUnion('type', [
z.object({ type: z.literal('circle'), radius: z.number() }),
z.object({ type: z.literal('rect'), width: z.number(), height: z.number() }),
]);
// refine:跨字段的业务规则(形状校验管不了的部分)
const RegisterSchema = z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: '两次密码不一致',
path: ['confirm'], // 把错误挂到具体字段上,前端才能显示在对应输入框下面
});
// transform:校验完之后顺手转换
const EmailSchema = z.email().transform((v) => v.toLowerCase());
再来个复用套路——把分页包一层泛型工厂,元素 schema 随便换:
1
2
3
4
5
6
7
8
const pageOf = <T extends z.ZodType>(item: T) =>
z.object({
items: z.array(item),
total: z.number().int().nonnegative(),
});
const UserPage = pageOf(UserSchema);
type UserPage = z.infer<typeof UserPage>; // { items: User[]; total: number }
面试点:refine 管”业务规则”,schema 本身只管”数据形状”。两者分工别混。
5. 错误处理:Zod 4 换了三个顶层函数
1
2
3
4
5
6
7
8
9
10
if (!result.success) {
// ❌ Zod 3 的实例方法(还能用,已废弃)
result.error.flatten(); // { formErrors, fieldErrors }
result.error.format();
// ✅ Zod 4 的顶层函数
z.flattenError(result.error); // 扁平结构 { formErrors, fieldErrors } -> 贴表单字段
z.treeifyError(result.error); // 嵌套结构,形状跟 schema 一致 -> 复杂嵌套对象
z.prettifyError(result.error); // 人类可读的多行字符串 -> 打日志
}
另外错误提示的写法也统一了。Zod 3 有 message / required_error / invalid_type_error / errorMap 四个入口,Zod 4 收成一个 error 参数:
1
2
3
4
5
6
// Zod 3:四个旋钮
z.string({ required_error: '必填', invalid_type_error: '必须是字符串' });
// ✅ Zod 4:一个 error,字符串或函数都行
z.string({ error: '名称必填' });
z.string({ error: (issue) => (issue.input === undefined ? '名称必填' : '名称必须是文本') });
6. Zod 4 迁移要点(知道这些很加分)
Zod 4 在 2025 年发布为稳定大版本,核心重写,官方数据:字符串解析快约 14 倍、对象解析快约 6.5 倍、tsc 类型实例化减少约 100 倍、核心包体积 gzip 从 12.5KB 降到 5.4KB。破坏性变更主要有这些:
| 变更 | Zod 3 | Zod 4 |
|---|---|---|
| 格式校验器 | z.string().email() | z.email() / z.uuid() / z.url() / z.iso.datetime()(顶层、可 tree-shake,旧写法仍可用但已废弃) |
| 错误定制 | message / required_error / invalid_type_error / errorMap | 统一为 error |
| 错误格式化 | error.flatten() / error.format() | z.flattenError() / z.treeifyError() / z.prettifyError() |
| 对象严格模式 | .strict() / .passthrough() | z.strictObject() / z.looseObject() |
record | z.record(z.number()) | 必须显式给键和值两个参数:z.record(z.string(), z.number())(单参数只是留着的 v3 兼容分支,类型上已经报红了) |
| 整数 | z.number().int() | 只接受安全整数(超出 MAX_SAFE_INTEGER 不再放行) |
| 枚举 | 原生 enum 用 z.nativeEnum() | 直接 z.enum(NativeEnum) |
| 元数据 | 无 | .meta({ id, title }) + z.toJSONSchema(),可生成 OpenAPI / 表单 |
还有两个实战坑:
- PATCH 接口的 schema 别用
.default():字段没传时它会被填成默认值,一更新就把数据库里的原值覆盖掉了。
1
2
3
4
5
6
7
// ❌ 复用带 default 的 schema:前端只想改 name,结果 status 被重置成 'draft'
const PatchSchema = z.object({ name: z.string().optional(), status: z.enum(['draft', 'on']).default('draft') });
PatchSchema.parse({ name: 'new' }); // { name: 'new', status: 'draft' } —— status 被凭空造出来了
// ✅ PATCH 单独写一份:全字段 optional,一个 default 都不带
const PatchSchemaOk = z.object({ name: z.string().optional(), status: z.enum(['draft', 'on']).optional() });
PatchSchemaOk.parse({ name: 'new' }); // { name: 'new' } —— 没传就是没传
- 体积敏感的前端 / 边缘函数用
@zod/mini(约 2KB),它是函数式 API、可 tree-shake;Node 后端或对开发体验要求高的场景用完整版。
7. 落地:三条边界各放一道闸
- 进程入口:环境变量、配置——
parse一次,不合规直接启动失败,比运行到一半才炸强得多。 - 网络边界:接口响应——
safeParse,失败就上报 + 降级,别让脏数据流进业务层。 - 用户边界:表单——前后端共用同一份 schema(放在共享包里),规则天然一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 网络边界的通用封装:校验失败不让脏数据进来,同时留下现场
async function safeFetch<T extends z.ZodType>(
url: string,
schema: T,
): Promise<z.infer<T>> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.json();
const parsed = schema.safeParse(raw);
if (!parsed.success) {
console.error('[schema mismatch]', url, z.prettifyError(parsed.error));
throw new Error('接口返回结构与约定不符');
}
return parsed.data;
}
const user = await safeFetch('/api/user/1', UserSchema); // 类型自动是 User
8. 什么时候不该用 / 该注意什么
- 热路径别全量校验:几万条数据的列表逐条
parse是有成本的,可以只校验首条 + 抽样,或者只在开发环境校验、生产关闭。 - 内部数据别重复校验:已经类型安全、不跨边界的数据再校验一遍纯属浪费。
- 它校验不了行为:Zod 管的是”数据长什么样”,
Date对象的合法性、class 实例要用z.instanceof(),函数、循环引用这类它本来就不是干这个的。
其实你每天都在用
- 环境变量校验:
EnvSchema.parse(process.env),项目一启动就告诉你少配了哪个变量 - 接口响应兜底:后端悄悄把
profile改成null,你这边立刻报错并上报,而不是用户看到白屏 - 表单校验:
react-hook-form+zodResolver,前后端共用一份 schema,规则永远不会不同步 - tRPC / Hono 的入参校验:
zValidator('json', Schema),校验通过后 handler 里的数据直接是类型安全的 - URL query / localStorage 反序列化:这些都是
string,拿出来必须重新校验 - AI 结构化输出:约束大模型必须返回符合 schema 的 JSON,比正则抠字符串靠谱得多
- 生成 OpenAPI / JSON Schema:
z.toJSONSchema()一份 schema 打通文档、Mock、表单
常见误解(FAQ)
❌ 误区1:”项目用了 TypeScript,就不需要运行时校验了” 恰恰相反。TS 类型编译后就没了,它对接口返回、用户输入、JSON.parse 的结果零保护。类型管编译期,schema 管运行期,两者是互补的。
❌ 误区2:”as 断言和 Zod 二选一,用 Zod 就不用断言了” 不是二选一。断言是”我告诉编译器闭嘴”,校验是”我让程序真的去检查”。正确做法是在边界处用 Zod 校验一次,之后整条链路再也不用断言。
❌ 误区3:”safeParse 成功就说明这份数据业务上合法” 不。schema 只能保证形状对:字段存在、类型正确、长度达标。”库存是否够”、”优惠券是否适用”这类业务规则得用 refine 单独写,或者在业务逻辑里判断。
❌ 误区4:”schema 里用了 async 校验,safeParse 也能正常返回失败” 不能。同步的 .parse() / .safeParse() 碰到异步 refine 会直接抛异常,必须换成 safeParseAsync / parseAsync。
❌ 误区5:”Zod 什么都能校验,class 实例、函数都行” 它校验的是可序列化的数据。class 实例要用 z.instanceof(Foo),函数是 z.function()(只能校验参数和返回值签名,校验不了函数体行为),循环引用、Symbol 键这类它本来就不是干这个的。
❌ 误区6:”z.infer 推出来的类型可以当接口文档用” 能当类型用,但不适合当文档。推导结果在编辑器里展开往往是一大坨条件类型,可读性差。对外暴露的公共类型建议 export type User = z.infer<typeof UserSchema> 之后再补注释,文档另写。
一句话总结
类型管编译期、Zod 管运行期;一份 schema 同时产出校验器和类型,把”外部数据不靠谱”这件事,死死关在系统边界上。