Zod 4 实践:把校验、类型和接口契约收在一处
去年我们线上出过一次诡异的事故:后端把订单接口的 amount 字段从 number 改成了字符串,给我们发了 "0"。前端的判断是 if (order.amount),字符串 "0" 是 truthy,于是一笔本该拦下的零元订单走完了下单流程。代码里 TypeScript 类型写得清清楚楚 amount: number,但那只是我们一厢情愿写在 interface 里的,运行时根本没人核对过后端到底发了什么。
这件事之后我重新审视了一个用了很久但一直没认真对待的工具——Zod。我们项目里早就装了它,但只在表单那一层用,接口边界完全是裸奔的。真正该做的是把校验、类型推导和接口契约都收到 schema 这一处,一份声明管三件事;正好赶上升级到 Zod 4,那些 API 变化踩的坑也一并说清楚。
为什么一个 schema 能当三样东西用
我以前的写法是这样的,类型和校验分开维护:
1// 类型定义 2interface User { 3 id: number 4 email: string 5 age: number 6} 7 8// 校验逻辑(另一个文件,另一套真理) 9function validateUser(input: unknown): input is User { 10 // ...一堆手写的 typeof 判断 11}
问题很直接:这俩东西永远会漂移。今天加个字段改了 interface,明天忘了同步校验函数。线上跑的是校验函数,类型只是给编辑器看的安慰剂。
Zod 的核心价值是把这两者合并成一份声明。你写一个 schema,类型从它推出来,校验也用它,运行时和编译期共用同一个真理:
1import { z } from "zod" 2 3const UserSchema = z.object({ 4 id: z.number().int().positive(), 5 email: z.email(), 6 age: z.number().int().min(0).max(150), 7}) 8 9// 类型不用手写,从 schema 推 10type User = z.infer<typeof UserSchema> 11// type User = { id: number; email: string; age: number }
改 schema 的瞬间,User 类型跟着变,所有用到它的地方该报错就报错。校验也是这一份。一处定义,三处受益:编译期类型、运行时校验、还有后面会讲的接口契约。
Zod 4 带来的变化
Zod 4 是个不算小的版本,我升级时踩了几个 API 调整。先说让我真正愿意升的两点。
第一是性能和包体。Zod 3 后期我们的一个大表单页面,schema 解析在低端机上能感觉到卡。Zod 4 重写了内部的解析路径,字符串校验这类高频操作快了一大截,TypeScript 编译时的类型实例化也减负了不少——我们那个有几百个 schema 的项目,tsc 不再动不动卡在某个深层泛型上。包体也明显小了,配合 tree-shaking 实际进 bundle 的代码少很多。
第二是 API 收敛得更一致了。最直观的就是格式校验从方法挪到了顶层函数:
1// Zod 3 的写法(4 里仍能用但已废弃) 2z.string().email() 3z.string().url() 4z.string().uuid() 5 6// Zod 4 推荐 7z.email() 8z.url() 9z.uuid()
这个改动一开始我有点不适应,但它背后是有道理的:z.string().email() 这种链式让人误以为可以任意叠加,实际上很多组合并没意义。顶层函数更明确,而且这些专用类型的实现也能单独优化。
日期时间类挪到了 z.iso 命名空间下,语义更清楚:
1z.iso.date() // "2026-05-03" 2z.iso.time() // "14:30:00" 3z.iso.datetime() // "2026-05-03T14:30:00Z" 4z.iso.duration() // ISO 8601 duration
错误定制这块也统一了。Zod 3 里有 message、invalid_type_error、required_error 好几个参数,记不住哪个用在哪。Zod 4 收成一个 error 参数,可以是字符串也可以是函数:
1const NameSchema = z.string({ 2 error: (issue) => 3 issue.input === undefined ? "名字是必填的" : "名字必须是字符串", 4}) 5 6// 单个校验也用 error 7const AgeSchema = z.number().min(18, { error: "必须年满 18 岁" })
函数形式能拿到 issue,按情况返回不同文案,比以前分散在几个参数里清爽。
还有一个容易忽略的:Zod 4 默认对 object 是 strip(剥掉未声明的字段)行为不变,但 .strict() 和 .passthrough() 的语义梳理得更清楚了,我后面讲接口解析时会用到。
表单:和 react-hook-form 配合
表单是 Zod 最常见的入口。我们用 react-hook-form 加官方的 resolver,校验规则就是 schema 本身,不用在 RHF 的 rules 里再写一遍。
1import { useForm } from "react-hook-form" 2import { zodResolver } from "@hookform/resolvers/zod" 3import { z } from "zod" 4 5const SignupSchema = z 6 .object({ 7 email: z.email("邮箱格式不对"), 8 password: z.string().min(8, "密码至少 8 位"), 9 confirm: z.string(), 10 }) 11 .refine((data) => data.password === data.confirm, { 12 error: "两次密码不一致", 13 path: ["confirm"], // 错误挂到 confirm 字段上 14 }) 15 16type SignupForm = z.infer<typeof SignupSchema> 17 18function SignupPage() { 19 const { 20 register, 21 handleSubmit, 22 formState: { errors }, 23 } = useForm<SignupForm>({ 24 resolver: zodResolver(SignupSchema), 25 }) 26 27 const onSubmit = (data: SignupForm) => { 28 // data 已经是校验通过的、类型为 SignupForm 的干净数据 29 console.log(data) 30 } 31 32 return ( 33 <form onSubmit={handleSubmit(onSubmit)}> 34 <input {...register("email")} /> 35 {errors.email && <span>{errors.email.message}</span>} 36 <input type="password" {...register("password")} /> 37 {errors.password && <span>{errors.password.message}</span>} 38 <input type="password" {...register("confirm")} /> 39 {errors.confirm && <span>{errors.confirm.message}</span>} 40 <button type="submit">注册</button> 41 </form> 42 ) 43}
注意 path: ["confirm"] 这个细节。跨字段的校验(比如两次密码一致)默认错误会挂在整个对象上,RHF 不知道往哪个输入框显示。指定 path 才能让错误落到对应的字段。这个我第一次写的时候漏了,调了半天为什么"两次密码不一致"提示不出来。
接口这一关:把"信任后端"改成"校验后端"
回到开头那次事故。修法不是去打补丁判断 amount === "0",而是在数据进入应用之前就把它挡在门口。我给每个接口的响应都写了 schema,拿到数据先 parse。
1const OrderSchema = z.object({ 2 id: z.string(), 3 amount: z.number(), // 后端要是发字符串,这里直接报错 4 status: z.enum(["pending", "paid", "cancelled"]), 5 createdAt: z.iso.datetime(), 6}) 7 8type Order = z.infer<typeof OrderSchema> 9 10async function fetchOrder(id: string): Promise<Order> { 11 const res = await fetch(`/api/orders/${id}`) 12 const json = await res.json() 13 // parse 失败会抛 ZodError,不会让脏数据流进业务 14 return OrderSchema.parse(json) 15}
如果后端真的发了 amount: "0",parse 会立刻抛错,错误信息精确到"expected number, received string at amount"。这比业务跑了一半才出问题强太多——数据一进门就报错,离病根最近。
但 parse 抛错这件事要想清楚怎么处理。它抛的是 ZodError,你不能让它一路冒泡到把整个页面白屏。生产里我更常用 safeParse,它不抛错,返回一个带 success 的结果对象:
1async function fetchOrder(id: string) { 2 const res = await fetch(`/api/orders/${id}`) 3 const json = await res.json() 4 const result = OrderSchema.safeParse(json) 5 6 if (!result.success) { 7 // result.error 是 ZodError,上报监控,给用户友好提示 8 reportToSentry("order schema mismatch", result.error.issues) 9 throw new Error("订单数据异常,请稍后重试") 10 } 11 return result.data // 类型安全的 Order 12}
我的取舍是:在能预期失败的地方(比如外部接口、用户输入)用 safeParse,把失败当成正常的控制流;在"理论上不该失败、失败就是 bug"的地方(比如解析自己写死的配置)用 parse,让它响亮地炸出来。
接口入参同理。提交给后端之前 parse 一遍出参,能在前端就拦掉本地拼错的请求体,不用等后端返回 400 再排查。
transform、refine、superRefine 做业务校验
Zod 不只是查类型,还能在校验过程里做转换和复杂判断。
.transform() 改变输出的值和类型,常用来归一化:
1const TrimmedString = z.string().transform((s) => s.trim()) 2 3const DateFromString = z.iso 4 .datetime() 5 .transform((s) => new Date(s)) // 输出类型变成 Date 6 7type ParsedDate = z.infer<typeof DateFromString> // Date
.refine() 做单字段的自定义断言,返回 boolean:
1const Username = z 2 .string() 3 .min(3) 4 .refine((s) => !s.includes(" "), { error: "用户名不能有空格" })
.superRefine() 用在需要往一个校验里抛多条错误、或者根据多个字段联合判断的场景。它给你一个 ctx,自己往里加 issue:
1const PriceRange = z 2 .object({ min: z.number(), max: z.number() }) 3 .superRefine((data, ctx) => { 4 if (data.min > data.max) { 5 ctx.addIssue({ 6 code: "custom", 7 message: "最小值不能大于最大值", 8 path: ["min"], 9 }) 10 } 11 if (data.max > 1_000_000) { 12 ctx.addIssue({ 13 code: "custom", 14 message: "最大值超出上限", 15 path: ["max"], 16 }) 17 } 18 })
refine 一次只报一条、判断只看一个返回值;superRefine 能在一次校验里报多条、精确控制每条挂哪个字段。表单里希望一次把所有错都告诉用户时,superRefine 更合适。
discriminatedUnion 处理多态响应
后端常有那种"一个接口返回多种形态"的设计,靠一个 type 字段区分。直接用 z.union 能跑,但报错信息很烂——它会把每个分支都试一遍,然后把所有失败堆给你。discriminatedUnion 告诉 Zod 用哪个字段做判别,先看判别字段命中哪个分支,再只校验那一个分支:
1const NotificationSchema = z.discriminatedUnion("type", [ 2 z.object({ 3 type: z.literal("message"), 4 from: z.string(), 5 text: z.string(), 6 }), 7 z.object({ 8 type: z.literal("payment"), 9 amount: z.number(), 10 currency: z.string(), 11 }), 12 z.object({ 13 type: z.literal("system"), 14 code: z.number(), 15 }), 16]) 17 18type Notification = z.infer<typeof NotificationSchema> 19 20function render(n: Notification) { 21 switch (n.type) { 22 case "message": 23 return n.text // 这里 TS 知道有 text 字段 24 case "payment": 25 return `${n.amount} ${n.currency}` 26 case "system": 27 return `code ${n.code}` 28 } 29}
推出来的类型是个判别联合,switch (n.type) 里能精确收窄。报错也准——type: "payment" 的数据缺了 amount,它直接说 payment 分支缺 amount,而不是把三个分支的失败全列出来。
zod/mini 与减包体的取舍
Zod 4 同时在主包里带了一个 zod/mini 子路径,API 从链式改成函数式组合,目标是更激进的 tree-shaking:
1import * as z from "zod/mini" 2 3const schema = z.object({ 4 name: z.string(), 5 age: z.optional(z.number()), 6})
它不是独立的包,装的还是同一个 zod,只是换一个入口导入。这样能砍掉相当一部分包体,适合对体积极敏感的场景,比如要塞进一个轻量 widget、或者边缘函数里。代价是写法没主包顺手,.min().max() 这种链式在 mini 里要写成嵌套的函数调用,可读性差一些。
我的选择是:主应用用标准 zod,开发体验优先,反正 4 的包体已经比 3 小很多;只有真的卡体积预算的独立产物才上 zod/mini。别一上来就为了几 KB 牺牲全项目的可读性。
和 valibot 的简单对比
升级期间我也认真看过 valibot。它的卖点就是极致小,API 也是函数式 pipe 风格,tree-shaking 友好:
1import * as v from "valibot" 2 3const Schema = v.object({ 4 email: v.pipe(v.string(), v.email()), 5})
valibot 的包体确实比标准 zod 小。但 Zod 4 把差距拉近了不少,而且 Zod 的生态压倒性——RHF resolver、tRPC、各种表单库都默认对接 Zod,社区文章和踩坑记录也多得多。我们最后留在 Zod,理由很现实:团队已经熟、生态成熟、Zod 4 的体积已经够用。valibot 是好东西,但换它的收益没大到值得动整个栈。
几个真踩过的坑
coerce 的陷阱。 z.coerce.number() 会先把输入强转成 number 再校验,对 query string 那种"啥都是字符串"的场景很方便。但 coerce 用的是 JS 原生转换,z.coerce.number().parse("") 得到的是 0(因为 Number("") === 0),z.coerce.boolean().parse("false") 是 true(非空字符串都 truthy)。这两个我都被坑过。需要严格判断时别用 coerce,老老实实先 z.string() 再 .transform 自己控制转换逻辑。
循环引用。 数据结构自引用(比如评论带子评论)不能直接写,需要用 getter:
1const CommentSchema = z.object({ 2 text: z.string(), 3 get replies() { 4 return z.array(CommentSchema) 5 }, 6}) 7 8type Comment = z.infer<typeof CommentSchema> 9// type Comment = { text: string; replies: Comment[] }
Zod 4 用 getter 的写法处理递归比 3 的 z.lazy 干净一些,这样类型可以自动推断出来,不用再手动标注 z.ZodType<Comment> 这类显式类型——get replies() 里返回 z.array(CommentSchema) 时,TypeScript 顺着这个 getter 就能推出自引用类型,不会再报循环引用错误。我一开始还是习惯性地手写了一遍类型接口摆在旁边对照,跑起来才发现完全是多余的一步。
parse 的错误别裸抛。 前面说过,parse 抛 ZodError,如果你在 React 渲染路径或者没有 try/catch 的 async 里直接 parse,一条脏数据就能白屏。养成习惯:拿不准数据来路的地方一律 safeParse,或者在调用层包一层统一捕获。
收尾说回开头那次事故。现在我们所有外部接口的响应都过一遍 schema,schema 和类型是同一份,校验和契约也是同一份。后端再偷偷改字段类型,前端会在数据入口第一时间报错并上报,而不是让一笔零元订单悄悄走完。Zod 真正帮我做到的,是把"我以为后端会发什么"变成了"我验证后端发了什么"——就这一个心态转变,省掉的排查时间已经远远值回票价了。