AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 当内置校验不够用时

到目前为止,我们见到的校验规则都是单个字段独立的:长度够不够、格式对不对、值在不在枚举里。

但真实业务里很多规则靠内置方法表达不出来:

  • 「确认密码」必须和「密码」一致
  • endDate 必须晚于 startDate
  • maxTokens 不能超过所选模型的上限
  • 角色是 admin 时,department 字段必须填
  • 一个聊天会话的 messages 必须以 systemuser 开头

这些规则有一个共同点:它们描述的是「数据之间的关系」或「业务层面的约束」,而不是单个字段的形状

Zod 给这类场景专门准备了两个方法:

index.ts
1
.refine(fn, options?) // 自定义单个条件
2
.superRefine((data, ctx) => { ... }) // 多错误 + 精细控制

这一篇我们把这两个方法吃透——它们是 Zod 从「描述数据形状」进化到「描述业务规则」的分水岭。

2. .refine():最常用的自定义校验

.refine(fn) 接收一个返回 boolean 的函数:返回 true 表示通过,false 表示不通过。

index.ts
1
const PasswordSchema = z.string().refine(
2
s => s.length >= 8,
3
{ message: '密码至少 8 位' }
4
)
5
6
PasswordSchema.parse('1234567') // 💥 密码至少 8 位
7
PasswordSchema.parse('12345678') // ✅

.refine() 的函数参数就是上一层校验通过后的数据。上面这个例子里,s 的类型就是 string——这一点很重要:

NOTE

.refine() 是在内置校验之后运行的。如果前置校验没过,refine 根本不会执行。

所以你可以在 .refine() 里放心地把参数当成「已经是正确类型」来用。

2.1 简写形式

如果只是想给错误信息,第二个参数可以直接传字符串:

index.ts
1
z.string().refine(s => s.length >= 8, '密码至少 8 位')

两种写法效果一样。对象形式的好处是还能配 pathparams(下一节讲)。

2.2 异步 refine

.refine() 的函数也可以是 async

index.ts
01
const UsernameSchema = z.string().refine(
02
async (name) => {
03
const exists = await db.user.findFirst({ where: { name } })
04
return !exists
05
},
06
{ message: '用户名已被占用' }
07
)
08
09
// 含异步 refine 的 schema 必须用 parseAsync / safeParseAsync
10
await UsernameSchema.parseAsync('alice')

这个上一篇讲过,在这里再提一下——.refine() 是引入异步校验的主要通道。

3. refine 的完整配置:message / path / params

.refine() 的第二个参数完整形式长这样:

index.ts
1
.refine(fn, {
2
message: '错误信息',
3
path: ['confirmPassword'], // 错误归属到哪个字段
4
params: { code: 'PASSWORD_WEAK' }, // 任意附加数据
5
})

最关键的是 path,它在「跨字段校验」里必不可少。我们马上看它怎么用。

4. 跨字段校验:refine 最常见的用法

把 refine 加在 object 外层,就能访问多个字段:

register-schema.ts
01
const RegisterSchema = z.object({
02
password: z.string().min(8),
03
confirmPassword: z.string(),
04
}).refine(
05
data => data.password === data.confirmPassword,
06
{
07
message: '两次输入的密码不一致',
08
path: ['confirmPassword'], // 错误挂到 confirmPassword 字段上
09
}
10
)

这里 data 的类型是 { password: string; confirmPassword: string },你可以对任意字段做判断。

4.1 path 为什么重要

看一下没有 path 的效果:

index.ts
1
// 没有 path:错误挂在对象根上
2
const r = RegisterSchema.safeParse({ password: 'abcd1234', confirmPassword: 'xxxx' })
3
console.log(r.error?.issues)
4
// [{ path: [], message: '两次输入的密码不一致' }]

前端拿到这种错误,没办法定位到具体输入框——因为 path 是空的。

加了 path: ['confirmPassword']

index.ts
1
// [{ path: ['confirmPassword'], message: '两次输入的密码不一致' }]

前端就能把错误显示在「确认密码」那个输入框下。这是所有表单 UI 库(react-hook-form、formik)依赖的基础约定。

原则:跨字段校验的 path 一定要指向「用户能理解的出错字段」。

4.2 日期范围校验

date-range.ts
01
const DateRangeSchema = z.object({
02
startDate: z.date(),
03
endDate: z.date(),
04
}).refine(
05
data => data.endDate > data.startDate,
06
{
07
message: '结束日期必须晚于开始日期',
08
path: ['endDate'],
09
}
10
)

模式一模一样:object schema + refine 外层 + path 指向重点字段。

5. .superRefine():多错误、更精细

.refine() 一次只能报一个错误,且错误 code 固定是 custom。真实业务里你可能需要:

  • 一份数据有多个问题,要一次性全部报给用户
  • 需要用不同的错误 code 区分不同问题
  • 条件满足时提前中止后续校验

这时候就该用 .superRefine()。它的函数签名是 (data, ctx) => void

index.ts
01
const schema = z.object({
02
password: z.string(),
03
}).superRefine((data, ctx) => {
04
const pwd = data.password
05
06
if (pwd.length < 8) {
07
ctx.addIssue({
08
code: 'custom',
09
path: ['password'],
10
message: '密码至少 8 位',
11
})
12
}
13
if (!/[A-Z]/.test(pwd)) {
14
ctx.addIssue({
15
code: 'custom',
16
path: ['password'],
17
message: '密码必须包含大写字母',
18
})
19
}
20
if (!/\d/.test(pwd)) {
21
ctx.addIssue({
22
code: 'custom',
23
path: ['password'],
24
message: '密码必须包含数字',
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 的典型用法是「前置条件挂了就没必要继续查」:

index.ts
01
.superRefine((data, ctx) => {
02
if (!data.userId) {
03
ctx.addIssue({
04
code: 'custom',
05
path: ['userId'],
06
message: 'userId 必填',
07
fatal: true,
08
})
09
return z.NEVER
10
}
11
12
// 下面这段只有在 userId 合法时才执行
13
if (data.userId.length !== 36) {
14
ctx.addIssue({ ... })
15
}
16
})

5.2 条件必填

superRefine 非常适合做「A 字段满足某条件时,B 字段必填」这类动态规则:

index.ts
01
const UserSchema = z.object({
02
role: z.enum(['admin', 'user']),
03
department: z.string().optional(),
04
}).superRefine((data, ctx) => {
05
if (data.role === 'admin' && !data.department) {
06
ctx.addIssue({
07
code: 'custom',
08
path: ['department'],
09
message: '管理员必须填写所属部门',
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 的通用约束:

index.ts
01
const ChatRequest = z.object({
02
messages: z.array(z.object({
03
role: z.enum(['system', 'user', 'assistant']),
04
content: z.string(),
05
})).min(1),
06
}).refine(
07
data => {
08
const first = data.messages[0].role
09
return first === 'system' || first === 'user'
10
},
11
{
12
message: '对话必须以 system 或 user 消息开头',
13
path: ['messages', 0], // 指向第一条消息
14
}
15
)

注意 path: ['messages', 0]——它指向数组的第一个元素,这是 Zod path 的标准表达。

7.2 maxTokens 不能超过模型上限

不同模型有不同的 context window,这是一个经典的「跨字段 + 外部知识」规则:

index.ts
01
const MODEL_LIMITS: Record<string, number> = {
02
'claude-opus-4-6': 200000,
03
'claude-haiku-4-5': 200000,
04
'gpt-4o': 128000,
05
}
06
07
const ChatRequest = z.object({
08
model: z.enum(['claude-opus-4-6', 'claude-haiku-4-5', 'gpt-4o']),
09
maxTokens: z.number().int().positive(),
10
}).refine(
11
data => data.maxTokens <= MODEL_LIMITS[data.model],
12
data => ({
13
message: `${data.model} 的 maxTokens 上限是 ${MODEL_LIMITS[data.model]}`,
14
path: ['maxTokens'],
15
})
16
)

这里第二个参数是一个函数,可以根据数据动态生成错误信息——这是 refine 的一个小技巧。

7.3 工具调用的参数必须匹配工具声明

一个更复杂的例子——前面还不合法,后面没必要校验。用 superRefine + fatal

index.ts
01
const TOOL_ARG_SCHEMAS: Record<string, z.ZodTypeAny> = {
02
search: z.object({ query: z.string().min(1), topK: z.number().int().min(1) }),
03
fetch: z.object({ url: z.string().url() }),
04
}
05
06
const ToolCallSchema = z.object({
07
name: z.string(),
08
args: z.record(z.unknown()),
09
}).superRefine((data, ctx) => {
10
const argSchema = TOOL_ARG_SCHEMAS[data.name]
11
if (!argSchema) {
12
ctx.addIssue({
13
code: 'custom',
14
path: ['name'],
15
message: `未知工具:${data.name}`,
16
fatal: true,
17
})
18
return z.NEVER
19
}
20
21
const result = argSchema.safeParse(data.args)
22
if (!result.success) {
23
result.error.issues.forEach(issue => {
24
ctx.addIssue({
25
code: 'custom',
26
path: ['args', ...issue.path],
27
message: issue.message,
28
})
29
})
30
}
31
})

这个例子有三个亮点:

  1. fatal + z.NEVER 在「未知工具名」时提前中止
  2. argSchema.safeParse 的结果转发ctx.addIssue——这是「组合 schema」的常见模式
  3. path 用展开运算拼接:既保留「是 args 下出的错」,又保留内部具体路径

日后你写 Agent、MCP、插件系统时,这个模式会反复出现。

8. 总结

这一篇我们从 Zod 的「描述形状」推进到了「描述规则」:

  1. .refine() — 单条件校验,写法直观,配 path 支持跨字段
  2. .superRefine() — 多错误、自定义 code、fatal 中止,适合复杂业务规则
  3. 核心原则:refine 只在前置校验通过后运行,函数参数的类型已经被 Zod 收窄
  4. path 不是装饰——它决定了错误在前端或日志里被挂到哪里,表单类场景尤其关键

一张能带走的选择表:

场景方法
「值必须满足 xxx」.refine(v => xxx)
「A 字段必须等于/大于 B 字段」.refine(data => ..., { path: [...] })
「同一字段多条规则都要报」.superRefine() 多个 addIssue
「前置挂了后面不用查」.superRefine() + fatal + z.NEVER
「查数据库再决定」.refine(async) + parseAsync

一句话带走:

NOTE

refine 让 schema 从「是什么类型」进化到「符合什么规则」——而规则里才藏着真正的业务。

下一篇我们讲 Zod 另一个让新手开眼的能力——transform:不仅校验数据,还能在 parse 过程中把它变成你真正想要的形态。