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 里有 messageinvalid_type_errorrequired_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 的错误别裸抛。 前面说过,parseZodError,如果你在 React 渲染路径或者没有 try/catch 的 async 里直接 parse,一条脏数据就能白屏。养成习惯:拿不准数据来路的地方一律 safeParse,或者在调用层包一层统一捕获。

收尾说回开头那次事故。现在我们所有外部接口的响应都过一遍 schema,schema 和类型是同一份,校验和契约也是同一份。后端再偷偷改字段类型,前端会在数据入口第一时间报错并上报,而不是让一笔零元订单悄悄走完。Zod 真正帮我做到的,是把"我以为后端会发什么"变成了"我验证后端发了什么"——就这一个心态转变,省掉的排查时间已经远远值回票价了。