用 Zod 做运行时数据校验:从安装到跑通订单校验场景的实操指南
2026/8/31 8:52:25 网站建设 项目流程

用 Zod 做运行时数据校验:从安装到跑通订单校验场景的实操指南

【免费下载链接】zodTypeScript-first schema validation with static type inference项目地址: https://gitcode.com/GitHub_Trending/zo/zod

上周一个线上事故的根因很无聊:接口在某个字段上多返回了null,前端代码按"必非空"直接解构,页面白屏,排查四十分钟后才定位到是类型只做了编译期声明、没有运行时校验。我用 Zod 把这类边界数据重新过了一遍——它是一个 TypeScript 优先的运行时 schema 校验库:用几行代码声明数据结构,.parse()既完成运行时校验,又把z.infer推导出的静态类型直接喂给下游,类型和校验规则从此不再漂移。

项目定位与选型判断

Zod 做的事很简单:你把数据结构声明成 schema,它负责"验证输入 + 推导类型"这两件事,z.infer拿到的类型就是 schema 本身,校验规则和类型定义不会分叉。

它和同类库(Joi、Yup、io-ts)的核心差异有三点:

  • 零第三方依赖。Zod 包本身没有任何 dependencies;Joi 的测试里甚至把 Zod 当作零依赖参照物来对比体积,这在整个校验库圈子里是独一份。
  • 类型是推导出来的,不是写出来的z.infer等价于z.output,输入侧类型还可以单独用z.input取,transformdefault这类会改变输出形态的 API 不会污染你的类型系统。
  • 不可变 API.min().optional().extend()都返回新实例,原 schema 不变,在模块间共享 schema 不用担心被谁悄悄改了。

什么场景值得用:TypeScript 项目里所有"数据从外部进来"的边界——用户输入、HTTP 请求体、第三方 API 响应、配置文件、localStorage。这些地方的数据在编译期没有任何类型承诺,Zod 在这里价值最大。

什么场景不必用:纯内部、完全由 TypeScript 构造的数据结构,加一层 parse 是纯开销;运行时本身就是 JavaScript 而不是 TypeScript 的项目,它"类型即产出"的核心卖点用不上。另外,如果你的代码库还在用 Zod v3 的旧习惯,仓库保留了 v3 兼容路径(zod/v3子入口),升级可以分步做。

五分钟上手

先装依赖,包管理器任选:

npm install zod # 或者 pnpm add zod # 或者 yarn add zod

最小闭环示例,覆盖"定义 → 验证 → 拿到结果":

import * as z from "zod"; // 定义订单号校验规则:非空字符串,最长 32 位 const OrderNo = z.string().min(1).max(32); // parse:校验通过则返回强类型结果,否则抛 ZodError const orderNo = OrderNo.parse("SO-20260830-001"); // safeParse:不抛异常,返回判别联合 { success, data | error } const result = OrderNo.safeParse(12345); if (!result.success) { console.log(result.error.issues); // → [{ expected: "string", code: "invalid_type", path: [], message: "Invalid input: expected string, received number" }] }

验证不通过时你会看到什么?parse抛出的ZodError里,err.issues是结构化数组,每条 issue 带expected(期望类型)、code(错误码)、path(出错位置的字段路径)和message(可读描述)。path是数组形式,所以嵌套字段会精确到["items", 1, "quantity"]这种深度,直接定位到"第 2 个商品的 quantity",不需要你自己在错误里做字符串匹配。完整错误结构见 错误处理文档。

核心能力拆解

上面是单字段的字符串规则。真实业务里你会反复用到下面三类能力。

对象 schema 与严格模式

z.object把字段规则组合成结构体,这是 Zod 里出现频率最高的 API。

import * as z from "zod"; const Order = z.object({ orderNo: z.string().min(1), // 订单号:必传 amount: z.number().min(0), // 订单金额:非负 remark: z.string().optional(), // 备注:可选,不传时为 undefined }); // 注意:v4 默认是 non-strict,未知字段会被静默丢弃 const data = Order.parse({ orderNo: "SO-001", amount: 99, debug: true }); // => { orderNo: "SO-001", amount: 99 } // debug 被丢掉了,没有任何报错

这里最容易踩的坑:v3 的z.object默认是 strict 的,v4 改成了"宽松"——多出来的字段不报错、直接被丢弃。如果你的业务需要"多一个字段就拒绝"(比如对外 API 防脏数据),要显式声明:

const StrictOrder = Order.strict(); // 或从源头就用 const Order = z.strictObject({ orderNo: z.string() });

宽松行为方便接收"只想要其中几个字段"的外部数据,但也意味着脏字段会无声消失。选型时想清楚:你要的是"过滤"还是"拒绝"。

对象相关的完整行为可以对照 对象校验测试 看,里面覆盖了 pick/omit/extend 等所有组合操作。

跨字段校验与错误格式化

单字段规则之外,业务里最常见的需求是"两个字段之间要有关系"——密码和确认密码一致、开始时间早于结束时间。这类规则放在对象层面用refine写:

const RegisterForm = z.object({ username: z.string().min(3).max(20), email: z.string().email(), password: z.string().min(8), confirmPassword: z.string(), }) // 跨字段校验:两次密码必须一致;path 指定后,错误会挂到 confirmPassword 字段下 .refine((data) => data.password === data.confirmPassword, { message: "两次密码输入不一致", path: ["confirmPassword"], });

拿到ZodError之后,如果你直接把它吐给前端,大概率还要自己写一遍"按 path 分组"的逻辑。v4 内置了两个格式化函数,按字段把 issues 展开成扁平结构,和表单控件一一对应:

const result = RegisterForm.safeParse(formValues); if (!result.success) { // 扁平化:formErrors 是顶层错误,fieldErrors 按字段名分组 const { formErrors, fieldErrors } = z.flattenError(result.error); // fieldErrors.confirmPassword => ["两次密码输入不一致"] }

进阶用法:嵌套结构(对象套数组套对象)用z.treeifyError()会得到镜像 schema 的树形结构,比 flatten 更适合深层数据;z.formatError()在 v4 已标记废弃,别用。三者选型看 错误格式化文档。

编解码与 AOT 编译

前两节解决"进得来、错得清"。还有一件事 Zod 4 做得比较完整:schema 不只是校验器,还是双向转换器。v4.1 起,任何 schema 都支持decode(输入 → 输出)和encode(输出 → 输入):

典型例子是"ISO 日期字符串 ↔ Date 对象":

const CreatedAt = z.codec( z.iso.datetime(), // 输入侧:ISO 字符串 z.date(), // 输出侧:Date 对象 { decode: (iso) => new Date(iso), // 入库前:字符串转 Date encode: (date) => date.toISOString(), // 返回前端:Date 转字符串 } );

z.infer拿到的永远是输出侧类型(Date),而z.input是输入侧类型(string),两个方向都不会类型错。

如果你只关心性能而不关心双向转换,v4 还有一个独立能力:z.compile(schema)把 schema 提前编译成扁平、无循环的校验函数。仓库基准测试的口径是:55 个 schema 的中位提速 2.4 倍,20 个字段的大对象和对象数组能到 9 倍左右,而裸z.string()几乎没有收益——它优化的是每节点派发和内存分配,简单 schema 没有可省的东西。

const CompiledOrder = z.compile(Order); // 对热路径单独编译 CompiledOrder.parse(input); // 用法与 Order.parse 完全一致

注意两个坑:一是.refine().extend()这类派生方法返回的是未编译的新 schema,要"先派生完再 compile";二是编译依赖new Function,CSP 严格环境(设置了z.config({ jitless: true }))下全局编译会自动关闭,此时z.compile是显式 opt-in。细节和基准代码在 compile 文档与 基准实现。

一个完整场景走通

把上面的能力串起来:一个用户注册接口,前端提交表单数据,服务端要完成字段校验、跨字段校验、错误回显三件事,并且入库的是强类型对象。

需求拆解:

  1. 输入不可信,任何字段都可能缺失或类型错乱——所以用safeParse而不是parse,不能靠异常流处理业务错误;
  2. 密码强度(至少 8 位、含数字)和"两次密码一致"是两条独立的跨字段/单字段规则,分别用refine表达;
  3. 错误要能按字段回显到表单,用z.flattenError一步到位。
import * as z from "zod"; const RegisterForm = z.object({ username: z.string().min(3, "用户名至少 3 个字符").max(20), email: z.string().email("邮箱格式不正确"), // 单字段 refine:密码强度规则,错误直接挂在 password 字段下 password: z.string().min(8).refine((v) => /\d/.test(v), { message: "密码需包含至少一个数字", }), confirmPassword: z.string(), }).superRefine((data, ctx) => { // superRefine:用 ctx.addIssue 精确控制 path,比 refine 的 path 选项更灵活 if (data.password !== data.confirmPassword) { ctx.addIssue({ code: "custom", message: "两次密码输入不一致", path: ["confirmPassword"], }); } });

服务端处理逻辑,整个函数不超过 20 行:

function handleRegister(body: unknown) { // safeParse 而非 parse:前端输入不可信,用返回值分支处理,不依赖 throw/catch const result = RegisterForm.safeParse(body); if (!result.success) { const { fieldErrors } = z.flattenError(result.error); // fieldErrors.username / fieldErrors.password ... 与表单控件一一对应 return { ok: false as const, fieldErrors }; } // 走到这里,result.data 的类型就是 { username: string; email: string; ... } // 入库函数可以按强类型签名定义,编译器会保证调用不出错 return { ok: true as const, user: insertUser(result.data) }; }
// 对照:合法输入与非法输入的两条路径 handleRegister({ username: "张三", email: "zhangsan@example.com", password: "Pass1234", confirmPassword: "Pass1234", }); // => { ok: true, user: {...} } handleRegister({ username: "ab", email: "bad", password: "short", confirmPassword: "other" }); // => { ok: false, fieldErrors: { username: ["用户名至少 3 个字符"], email: ["邮箱格式不正确"], ... } }

两个关键决策点回顾一下:safeParse是因为错误是业务正常分支而不是异常,用判别联合比 try/catch 干净;superRefine+ctx.addIssue是因为跨字段错误需要精确指定挂到哪个字段,refinepath选项够用但表达力弱一档。如果这条接口在热路径上(比如 QPS 很高的注册网关),把z.compile(RegisterForm)放在模块加载时执行一次即可,用法不变。

你会被问到的问题

Q:parsesafeParse到底选哪个?parse在失败时抛ZodErrorsafeParse返回{ success, data | error }判别联合。不可信输入(用户、第三方 API)用safeParse,因为错误是常态分支不是异常;内部可信数据用parse可以让"不该发生的情况"直接炸出来。

Q:z.inferz.output有什么区别?没有区别,z.infer就是z.output的别名。需要区分的是输入侧类型z.input——经过transformdefaultcodec后,输入输出类型会分叉,这时候两个都要用。

Q:z.coerce.number()把空字符串转成了什么?NaNcoerce本质是"先强转再校验",""强转数字就是NaN,而 Zod 的 number 规则默认拒绝NaN。如果你要接受空串,显式.catch(0)或先判断,别指望 coerce 帮你兜底。

Q:v4 为什么我多传了字段不报错?v4 的 object 默认丢弃未知字段(v3 是严格拒绝)。要恢复拒绝行为用z.strictObject.strict();中间态"允许白名单外的字段但报错"用.catchall(z.never())之类的组合。这是 v3→v4 迁移时最常撞到的行为差异。

Q:schema 里有异步 refine,为什么parse拿不到值?异步规则(refine(async ...))必须配parseAsync/safeParseAsync,同步版本会直接拒绝。safeParse的返回类型里也不会有异步数据的类型。

延伸方向与下一步

  • JSON Schema 互转toJSONSchema/fromJSONSchema内置,可以用 Zod schema 反向生成 OpenAPI 文档,或者把第三方给的 JSON Schema 直接变成可运行的校验器,实现在 JSON Schema 生成器与 转换测试。
  • 错误消息国际化:内置 50+ 语言 locale,按 locales 目录 引入对应语言包即可,错误文案随 locale 切换而不用维护第二套文案。
  • 性能与体积:热路径上z.compile是正解(基准见 compile-matrix);如果在意 bundle 体积,zod/mini子包提供函数式 API 的紧凑版本,树摇测试在 treeshake 包。

Zod 的能力边界到这里其实已经够用了:schema 声明一处,类型、校验、错误格式化、JSON Schema 全部从这一处推导。剩下的就是按数据边界逐个接口铺开。

【免费下载链接】zodTypeScript-first schema validation with static type inference项目地址: https://gitcode.com/GitHub_Trending/zo/zod

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询