1. 当内置校验不够用时
到目前为止,我们见到的校验规则都是单个字段独立的:长度够不够、格式对不对、值在不在枚举里。
但真实业务里很多规则靠内置方法表达不出来:
- 「确认密码」必须和「密码」一致
endDate必须晚于startDatemaxTokens不能超过所选模型的上限- 角色是
admin时,department字段必须填 - 一个聊天会话的
messages必须以system或user开头
这些规则有一个共同点:它们描述的是「数据之间的关系」或「业务层面的约束」,而不是单个字段的形状。
Zod 给这类场景专门准备了两个方法:
1.refine(fn, options?) // 自定义单个条件2.superRefine((data, ctx) => { ... }) // 多错误 + 精细控制
这一篇我们把这两个方法吃透——它们是 Zod 从「描述数据形状」进化到「描述业务规则」的分水岭。
2. .refine():最常用的自定义校验
.refine(fn) 接收一个返回 boolean 的函数:返回 true 表示通过,false 表示不通过。
1const PasswordSchema = z.string().refine(2s => s.length >= 8,3{ message: '密码至少 8 位' }4)56PasswordSchema.parse('1234567') // 💥 密码至少 8 位7PasswordSchema.parse('12345678') // ✅
.refine() 的函数参数就是上一层校验通过后的数据。上面这个例子里,s 的类型就是 string——这一点很重要:
.refine() 是在内置校验之后运行的。如果前置校验没过,refine 根本不会执行。
所以你可以在 .refine() 里放心地把参数当成「已经是正确类型」来用。
2.1 简写形式
如果只是想给错误信息,第二个参数可以直接传字符串:
1z.string().refine(s => s.length >= 8, '密码至少 8 位')
两种写法效果一样。对象形式的好处是还能配 path 和 params(下一节讲)。
2.2 异步 refine
.refine() 的函数也可以是 async:
01const UsernameSchema = z.string().refine(02async (name) => {03const exists = await db.user.findFirst({ where: { name } })04return !exists05},06{ message: '用户名已被占用' }07)0809// 含异步 refine 的 schema 必须用 parseAsync / safeParseAsync10await UsernameSchema.parseAsync('alice')
这个上一篇讲过,在这里再提一下——.refine() 是引入异步校验的主要通道。
3. refine 的完整配置:message / path / params
.refine() 的第二个参数完整形式长这样:
1.refine(fn, {2message: '错误信息',3path: ['confirmPassword'], // 错误归属到哪个字段4params: { code: 'PASSWORD_WEAK' }, // 任意附加数据5})
最关键的是 path,它在「跨字段校验」里必不可少。我们马上看它怎么用。
4. 跨字段校验:refine 最常见的用法
把 refine 加在 object 外层,就能访问多个字段:
01const RegisterSchema = z.object({02password: z.string().min(8),03confirmPassword: z.string(),04}).refine(05data => data.password === data.confirmPassword,06{07message: '两次输入的密码不一致',08path: ['confirmPassword'], // 错误挂到 confirmPassword 字段上09}10)
这里 data 的类型是 { password: string; confirmPassword: string },你可以对任意字段做判断。
4.1 path 为什么重要
看一下没有 path 的效果:
1// 没有 path:错误挂在对象根上2const r = RegisterSchema.safeParse({ password: 'abcd1234', confirmPassword: 'xxxx' })3console.log(r.error?.issues)4// [{ path: [], message: '两次输入的密码不一致' }]
前端拿到这种错误,没办法定位到具体输入框——因为 path 是空的。
加了 path: ['confirmPassword']:
1// [{ path: ['confirmPassword'], message: '两次输入的密码不一致' }]
前端就能把错误显示在「确认密码」那个输入框下。这是所有表单 UI 库(react-hook-form、formik)依赖的基础约定。
原则:跨字段校验的 path 一定要指向「用户能理解的出错字段」。
4.2 日期范围校验
01const DateRangeSchema = z.object({02startDate: z.date(),03endDate: z.date(),04}).refine(05data => data.endDate > data.startDate,06{07message: '结束日期必须晚于开始日期',08path: ['endDate'],09}10)
模式一模一样:object schema + refine 外层 + path 指向重点字段。
5. .superRefine():多错误、更精细
.refine() 一次只能报一个错误,且错误 code 固定是 custom。真实业务里你可能需要:
- 一份数据有多个问题,要一次性全部报给用户
- 需要用不同的错误 code 区分不同问题
- 条件满足时提前中止后续校验
这时候就该用 .superRefine()。它的函数签名是 (data, ctx) => void:
01const schema = z.object({02password: z.string(),03}).superRefine((data, ctx) => {04const pwd = data.password0506if (pwd.length < 8) {07ctx.addIssue({08code: 'custom',09path: ['password'],10message: '密码至少 8 位',11})12}13if (!/[A-Z]/.test(pwd)) {14ctx.addIssue({15code: 'custom',16path: ['password'],17message: '密码必须包含大写字母',18})19}20if (!/\d/.test(pwd)) {21ctx.addIssue({22code: 'custom',23path: ['password'],24message: '密码必须包含数字',25})26}27})
传入一个只有小写字母的密码,这个 schema 会一次性报三条错误,而不是报完第一条就停。
5.1 ctx 的常用方法
| 方法 | 作用 |
|---|---|
ctx.addIssue({ code, path, message }) | 加一个错误,但继续后面的逻辑 |
ctx.addIssue({ ..., fatal: true }) + return z.NEVER | 加错误并终止后续 refine |
ctx.path | 当前 refine 所在路径(在嵌套校验里有用) |
fatal + z.NEVER 的典型用法是「前置条件挂了就没必要继续查」:
01.superRefine((data, ctx) => {02if (!data.userId) {03ctx.addIssue({04code: 'custom',05path: ['userId'],06message: 'userId 必填',07fatal: true,08})09return z.NEVER10}1112// 下面这段只有在 userId 合法时才执行13if (data.userId.length !== 36) {14ctx.addIssue({ ... })15}16})
5.2 条件必填
superRefine 非常适合做「A 字段满足某条件时,B 字段必填」这类动态规则:
01const UserSchema = z.object({02role: z.enum(['admin', 'user']),03department: z.string().optional(),04}).superRefine((data, ctx) => {05if (data.role === 'admin' && !data.department) {06ctx.addIssue({07code: 'custom',08path: ['department'],09message: '管理员必须填写所属部门',10})11}12})
这种规则用 TS 的联合类型也能表达,但运行时必须有人来检查它——superRefine 就是这个角色。
6. refine vs superRefine 怎么选
两种方法能做的事有重叠,但各自擅长的场景不同:
| 你的需求 | 用哪个 |
|---|---|
| 一次只判断一个条件,一次只报一个错 | .refine() |
| 错误要挂在另一个字段上(跨字段) | .refine() + path |
| 同一个字段有多条校验规则,想全报出来 | .superRefine() |
| 需要用不同的错误 code | .superRefine() |
| 有前置依赖(A 错了不用查 B) | .superRefine() + fatal |
| 异步校验(查数据库) | .refine(async (v) => ...) |
日常 80% 的场景用 .refine() 就够,.superRefine() 是留给那 20% 复杂业务规则的。不要一上来就 superRefine——它更灵活,但也更啰嗦。
7. 实战:AI 场景的业务规则
我们用三个 AI 项目里真实会遇到的规则,把这一篇的东西串起来。
7.1 messages 必须以 system 或 user 开头
这是 OpenAI 和 Anthropic 的通用约束:
01const ChatRequest = z.object({02messages: z.array(z.object({03role: z.enum(['system', 'user', 'assistant']),04content: z.string(),05})).min(1),06}).refine(07data => {08const first = data.messages[0].role09return first === 'system' || first === 'user'10},11{12message: '对话必须以 system 或 user 消息开头',13path: ['messages', 0], // 指向第一条消息14}15)
注意 path: ['messages', 0]——它指向数组的第一个元素,这是 Zod path 的标准表达。
7.2 maxTokens 不能超过模型上限
不同模型有不同的 context window,这是一个经典的「跨字段 + 外部知识」规则:
01const MODEL_LIMITS: Record<string, number> = {02'claude-opus-4-6': 200000,03'claude-haiku-4-5': 200000,04'gpt-4o': 128000,05}0607const ChatRequest = z.object({08model: z.enum(['claude-opus-4-6', 'claude-haiku-4-5', 'gpt-4o']),09maxTokens: z.number().int().positive(),10}).refine(11data => data.maxTokens <= MODEL_LIMITS[data.model],12data => ({13message: `${data.model} 的 maxTokens 上限是 ${MODEL_LIMITS[data.model]}`,14path: ['maxTokens'],15})16)
这里第二个参数是一个函数,可以根据数据动态生成错误信息——这是 refine 的一个小技巧。
7.3 工具调用的参数必须匹配工具声明
一个更复杂的例子——前面还不合法,后面没必要校验。用 superRefine + fatal:
01const TOOL_ARG_SCHEMAS: Record<string, z.ZodTypeAny> = {02search: z.object({ query: z.string().min(1), topK: z.number().int().min(1) }),03fetch: z.object({ url: z.string().url() }),04}0506const ToolCallSchema = z.object({07name: z.string(),08args: z.record(z.unknown()),09}).superRefine((data, ctx) => {10const argSchema = TOOL_ARG_SCHEMAS[data.name]11if (!argSchema) {12ctx.addIssue({13code: 'custom',14path: ['name'],15message: `未知工具:${data.name}`,16fatal: true,17})18return z.NEVER19}2021const result = argSchema.safeParse(data.args)22if (!result.success) {23result.error.issues.forEach(issue => {24ctx.addIssue({25code: 'custom',26path: ['args', ...issue.path],27message: issue.message,28})29})30}31})
这个例子有三个亮点:
- 用
fatal + z.NEVER在「未知工具名」时提前中止 - 把
argSchema.safeParse的结果转发进ctx.addIssue——这是「组合 schema」的常见模式 path用展开运算拼接:既保留「是args下出的错」,又保留内部具体路径
日后你写 Agent、MCP、插件系统时,这个模式会反复出现。
8. 总结
这一篇我们从 Zod 的「描述形状」推进到了「描述规则」:
.refine()— 单条件校验,写法直观,配path支持跨字段.superRefine()— 多错误、自定义 code、fatal 中止,适合复杂业务规则- 核心原则:refine 只在前置校验通过后运行,函数参数的类型已经被 Zod 收窄
- path 不是装饰——它决定了错误在前端或日志里被挂到哪里,表单类场景尤其关键
一张能带走的选择表:
| 场景 | 方法 |
|---|---|
| 「值必须满足 xxx」 | .refine(v => xxx) |
| 「A 字段必须等于/大于 B 字段」 | .refine(data => ..., { path: [...] }) |
| 「同一字段多条规则都要报」 | .superRefine() 多个 addIssue |
| 「前置挂了后面不用查」 | .superRefine() + fatal + z.NEVER |
| 「查数据库再决定」 | .refine(async) + parseAsync |
一句话带走:
refine 让 schema 从「是什么类型」进化到「符合什么规则」——而规则里才藏着真正的业务。
下一篇我们讲 Zod 另一个让新手开眼的能力——transform:不仅校验数据,还能在 parse 过程中把它变成你真正想要的形态。