前端表单校验和 Zod:类型和运行时规则要对齐

同一个字段,编译器说它是 string,运行时它却可能是 undefined——这不是矛盾,这是两套机制在各说各话。

TypeScript 里写一份类型,表单里写一份校验,提交前又手写一份清洗。三份规则来自三个地方,时间一长就对不齐:类型说字段必填,校验却允许为空;校验说手机号 11 位,接口实际还要求区号。平时不出事,是因为进来的数据恰好合法;一旦某个边界数据不合法,编译期那份「类型」帮不上任何忙,因为它编译完就没了。这个错位才是表单校验真正难缠的地方,而 Zod 这类 schema 工具的价值,就是把运行时校验和 TypeScript 类型收敛到同一个来源。

类型只在编译期成立,运行时它就不在了

先把这个错位摊开看。前端经常这么写:

1type LoginForm = {
2  username: string
3  password: string
4}

这份类型只约束你写代码时的行为。可用户输入、URL 参数、接口返回、localStorage 里的数据,全都是运行时才进来的,TypeScript 编译时根本看不到它们,自然也不会检查:

1const form = JSON.parse(localStorage.getItem('draft') || '{}') as LoginForm

这里的 as LoginForm 只是告诉编译器「相信我,这是 LoginForm」,它没做任何验证。localStorage 里那份 JSON 可能是上个版本存的、结构早变了,也可能被用户手动改过。类型断言只是把编译器的嘴堵上,运行时该崩还是崩。

Zod 补的正是这一层——它是运行时真实存在的代码,会真的去检查数据:

1const LoginSchema = z.object({
2  username: z.string().min(1, '请输入账号'),
3  password: z.string().min(6, '密码至少 6 位'),
4})
5
6type LoginForm = z.infer<typeof LoginSchema>

关键在 z.infer:类型是从 schema 推出来的,运行时校验也用同一份 schema。这样编译期那份类型和运行时那套规则不再是两个独立来源,改一处、两处一起变,错位从根上消失了。

safeParse 把错误落到具体字段

表单校验不是只返回 true/false。校验失败时,用户得知道到底哪个字段错了、错在哪。

1const result = LoginSchema.safeParse(values)
2
3if (!result.success) {
4  const fieldErrors = result.error.flatten().fieldErrors
5  setErrors(fieldErrors)
6}

safeParseparse 更适合表单——parse 校验失败会直接 throw,你得包 try/catch;safeParse 返回一个带 success 标志的结果,方便把错误顺手回填到 UI。flatten().fieldErrors 会给出一个「字段名到错误消息数组」的映射,正好对得上表单结构。

展示时把错误和字段关联起来,顺带把无障碍属性也带上:

1<input aria-invalid={Boolean(errors.username)} />
2{errors.username ? <p>{errors.username[0]}</p> : null}

API 错误也走类似路径。本地校验挡住格式问题,服务端校验返回业务问题——账号已存在、验证码过期、权限不足。这里又是那个错位的另一面:本地类型和本地校验都通过了,不代表服务端会放行,业务规则这份「运行时真相」只有后端知道,所以服务端返回的字段级错误也得能落回对应输入框。

清洗规则放进 schema,别散在提交处

用户输入经常要清洗:去空格、字符串转数字、空字符串转 undefined。别在提交时到处手写:

1submit({
2  age: Number(values.age),
3  name: values.name.trim(),
4})

手写清洗又是一份游离在 schema 之外的规则,迟早和校验规则打架。把它收进 schema,让转换和校验在同一个地方发生:

1const UserSchema = z.object({
2  name: z.string().trim().min(1, '姓名不能为空'),
3  age: z.coerce.number().int().min(1, '年龄不合法'),
4})

这样 safeParse 成功后拿到的 result.data 就是清洗后的结果,类型也是转换后的类型(agenumber 而不是 string)——输入的形状和输出的形状可以不同,Zod 会把这个差异体现在推断的类型里。

不过要小心 coerce。它很宽松:z.coerce.number() 遇到空字符串会得到 0,遇到 "abc" 才会失败。空字符串转成 0 是否符合业务,得自己确认,别默认它替你把关。校验工具能保证「数据符合 schema」,但「schema 是否符合业务」还得人来定,这两者不能混。想要更严的转换,可以先 preprocess 把空串归一成 undefined 再校验,把「空」和「0」分开处理。

跨字段规则用 refine,注意 path

很多表单规则不是单字段能决定的。比如结束时间不能早于开始时间:

1const RangeSchema = z.object({
2  startAt: z.date(),
3  endAt: z.date(),
4}).refine((data) => data.endAt >= data.startAt, {
5  path: ['endAt'],
6  message: '结束时间不能早于开始时间',
7})

path 很关键,它决定错误落到哪个字段。不指定的话,错误只挂在表单级别,用户看到一句「时间不对」却不知道该改哪个输入框。更复杂的场景用 superRefine,可以一次加多个错误、落到不同字段:

1const Schema = z.object({
2  password: z.string(),
3  confirmPassword: z.string(),
4}).superRefine((data, ctx) => {
5  if (data.password !== data.confirmPassword) {
6    ctx.addIssue({
7      code: z.ZodIssueCode.custom,
8      path: ['confirmPassword'],
9      message: '两次密码不一致',
10    })
11  }
12})

有个坑要提前知道:refinetransform 之后返回的是 ZodEffects,它不再是纯 ZodObject,所以你没法直接对它继续 .extend().pick().merge()。想复用又想加跨字段规则,顺序上要先把对象组合好,最后再 refine——这是 schema 复用时最容易撞的墙。

前后端共享 schema:共享基础,各自组合

不少团队想把 Zod schema 前后端共享,方向是对的,但要守住边界。前端表单 schema 和后端接口 schema 不一定完全一样:前端可能有确认密码、临时上传文件、纯展示字段;后端可能有权限字段、审计字段、服务端生成的 id 和时间戳。

我的做法是共享基础规则,前端再往上组合:

1const UserBaseSchema = z.object({
2  name: z.string().min(1),
3  email: z.string().email(),
4})
5
6const UserFormSchema = UserBaseSchema.extend({
7  confirmEmail: z.string().email(),
8})

别为了共享而让一个 schema 同时服务所有场景,最后会退化成一堆可选字段加条件判断,谁都不敢改。共享的应该是「这个领域对象最核心那份不变的约束」,场景差异各自 extendpickomit 出来。

顺带提一个 2023 年常配套的用法:校验接口响应。别默认后端永远返回你以为的结构,关键接口在数据层用 schema 过一道,字段缺了、类型变了当场就能定位,而不是等页面某个深层组件读到 undefined 才崩。这和表单校验是同一件事——都是在运行时给「数据到底长什么样」一个能执行的答案,只不过一个把关用户输入,一个把关服务端输出。

和表单库对接:别让 schema 和库各校一遍

手写 safeParse 加回填 errors 能跑,但字段一多,onChange 时机、touched 状态、focus 到第一个错误字段这些体力活会把组件撑得很臃肿。这一层交给成熟表单库更合适。到 2023 年,React 这边 react-hook-form 已经很成熟,它提供了 @hookform/resolvers 让 schema 直接接上去:

1import { useForm } from 'react-hook-form'
2import { zodResolver } from '@hookform/resolvers/zod'
3
4const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
5  resolver: zodResolver(LoginSchema),
6})

这里的关键是:校验规则仍然只有 schema 这一份,resolver 只是把 Zod 的错误结构翻译成表单库认识的格式。别掉进另一个坑——一边写 schema、一边又在 register('username', { required: true }) 里塞库自带的校验规则,那又变回两套规则各说各话了,正是这篇一开始要解决的错位。用了 resolver,字段级规则就全放 schema,库那边只管交互。

Vue 这边同理,VeeValidate 有 @vee-validate/zodtoTypedSchema,思路一样:schema 是唯一真相,表单库只负责 UI 状态。选哪个库是次要的,守住「规则单一来源」才是重点。

异步校验:格式和唯一性是两回事

有些规则本地根本判断不了,比如「账号是否已被占用」,这得问服务端。Zod 支持异步 refine,但它会让整个 schema 变成异步,得用 parseAsync/safeParseAsync

1const RegisterSchema = z.object({
2  username: z.string().min(1),
3}).refine(
4  async (data) => {
5    const taken = await checkUsername(data.username)
6    return !taken
7  },
8  { path: ['username'], message: '该账号已被占用' },
9)
10
11const result = await RegisterSchema.safeParseAsync(values)

但我很少让异步校验跟着每次输入跑——用户每敲一个字就打一次接口,既费流量又容易触发竞态(先发的慢请求后回来覆盖新结果)。实践上更稳的是:本地格式校验实时跑,唯一性这类异步校验放到失焦或提交时再触发,接口层再配防抖和请求取消。「格式对不对」和「业务上允不允许」是两个层次,别混在同一次校验里同步做。

联合类型和错误文案

表单里常有「根据某个字段决定其余字段」的结构,比如通知方式选了「短信」要填手机号、选了「邮件」要填邮箱。这种用 discriminatedUnion 比一堆可选字段加 refine 清爽得多:

1const NotifySchema = z.discriminatedUnion('channel', [
2  z.object({ channel: z.literal('sms'), phone: z.string().length(11) }),
3  z.object({ channel: z.literal('email'), email: z.string().email() }),
4])

它按 channel 这个判别字段分支校验,类型推断出来也是精确的联合类型,不会出现「明明选了短信,邮箱字段还被当必填」的错位。

错误文案也值得统一。Zod 默认的英文报错不能直接给用户看,除了在每条规则上手写 message,还可以用 z.setErrorMap 集中定制,把「太短」「类型不对」这类通用错误统一翻译,避免每个 schema 各写各的中文:

1z.setErrorMap((issue, ctx) => {
2  if (issue.code === z.ZodIssueCode.too_small) {
3    return { message: `至少需要 ${issue.minimum} 个字符` }
4  }
5  return { message: ctx.defaultError }
6})

这样字段级只写业务相关的特定文案,通用文案走全局错误映射,前后端如果都用 Zod,甚至可以共享同一份错误映射,提示口径就统一了。

可选、可空和默认值:三件不同的事

表单里最容易含糊的,是「这个字段可以不填」到底指什么。Zod 把它拆成了几个语义不同的方法,混用会得到和预期不符的校验结果:

1z.string().optional() // 值可以是 undefined(字段可以整个不存在)
2z.string().nullable() // 值可以是 null(字段在,但显式为空)
3z.string().nullish()  // undefined 或 null 都行
4z.string().default('') // 缺失时补默认值,输出一定有值

这几个在表单里对应不同场景:一个可以完全不提交的可选字段用 optional;接口约定「不填就传 null」的用 nullable;想让缺失的字段自动补个默认值、下游不用再判空的用 default。选错的后果很具体——该拦住空值的地方放行了,或者本可以自动补默认值的地方还在到处写判空。想清楚「空」在这个字段上到底意味着什么,再挑对应的方法。

还有个实际的坑:HTML 表单里空输入框拿到的是空字符串 '',不是 undefined。所以 z.string().optional() 拦不住一个「留空」的输入框,因为 '' 是合法字符串。想让「留空」等同于「没填」,得先把空串归一化:z.string().trim().min(1) 或者 z.preprocess((v) => v === '' ? undefined : v, z.string().optional())。这类空值语义的错位,是表单校验里最常见的一类「明明写了校验却没拦住」。

输入类型和输出类型不是一回事

有个 Zod 用久了才会在意的细节:一个带 transformcoercedefault 的 schema,它「接受什么」和「产出什么」类型可以完全不同。z.infer 拿到的是输出类型,如果你想拿输入类型,得用 z.input

1const Schema = z.object({
2  age: z.coerce.number(),           // 输入 string | number,输出 number
3  role: z.string().default('user'), // 输入可选,输出必有
4})
5
6type Input = z.input<typeof Schema>   // { age: unknown; role?: string }
7type Output = z.output<typeof Schema>  // { age: number; role: string }

这个区分在表单里很实际。表单组件绑定的值往往是字符串(输入类型),而 safeParse 成功后交给提交函数的是清洗过的值(输出类型)。把两者混用,要么类型对不上得到处断言,要么运行时拿到没想到的形状。想清楚「这个变量是校验前还是校验后」,很多类型报错就顺了。

再往深一点,Zod 支持 branded 类型给基础类型打标记,避免把「任意字符串」误当成「已校验的邮箱」:

1const Email = z.string().email().brand<'Email'>()
2type Email = z.infer<typeof Email> // string & { __brand: 'Email' }

这样一个普通 string 就传不进要求 Email 的地方,编译期就能拦住「没校验就当合法邮箱用」的错误。这算进阶用法,一般项目不必上,但在核心链路上给关键值加个 brand,能让「这个值到底校验过没有」变成类型能表达的事,而不是靠人记。

什么地方该上 schema,什么地方不必

我会优先在这些地方用 schema:

  • 字段多、规则杂的复杂表单
  • URL 参数解析(?tab=xxx&page=2 这类进来就是字符串,还可能被人乱改)
  • localStorage 草稿恢复(结构可能是旧版本存的)
  • 关键接口响应校验
  • 前后端共享的 DTO 边界

这些地方不一定需要:只有一两个简单字段的小表单、完全静态的配置、已经被成熟表单库加后端强校验覆盖的低风险页面。给一个只有邮箱一个字段的登录框套一整套 schema,属于为了类型化而类型化,收益和维护成本不成比例。

Zod 的价值不是让代码看起来更「类型安全」,而是把编译期和运行时那道天然的错位补上——让「这个数据到底应该长什么样」有一个运行时也能执行、且和类型同源的答案。类型管不到的地方,才是校验真正要站岗的地方。