《TypeScript编程实战》13.2 表单类型推导与错误映射

Zod 的 transform 与 default 会让 schema 的输入与输出变成两副面孔,表单类型也跟着分叉。本节用实测结果讲清 z.input、z.output 与 z.infer 的关系,给出 useForm 三段泛型的正确写法,并解释写错时编译器报出的 Resolver 不兼容信息。随后把 ZodError 映射成 RHF 的 FieldErrors,覆盖嵌套路径与服务端错误回填。

本节目标:搞清楚 z.input、z.output、z.infer 三者的关系,知道表单类型为什么会「分叉」,并能写出 useForm 的三段泛型让输入与输出各就各位。同时掌握 ZodError.issues 到 RHF FieldErrors 的映射规则,包括嵌套字段、数组字段、多错误收集,以及服务端错误如何回填到具体输入框。

13.2 表单类型推导与错误映射

13.1 留了一个尾巴:useForm<FormValues> 用的其实是 schema 的输出类型,而表单里能填的却是输入类型。多数场景下两者恰好一样,所以这个错误不会暴露;但只要 schema 里出现一次 transform、default 或 coerce,两副面孔就分开了,类型会在最不该出错的地方开始骗你。

13.2.1 三个类型

Zod 为每个 schema 暴露了三样东西,名字很像,语义完全不同:

类型工具含义对应表单里的角色
z.input<S>parse 接受的类型输入框里能填什么、register 绑定的字段类型
z.output<S>parse 产出的类型handleSubmit 回调收到的值、发给后端的 payload
z.infer<S>z.output<S> 的别名同上,只是写法更短

记住一句话:z.infer 不是「输入类型」,它是输出类型。这是最容易被误用的一点,因为大多数教程里 schema 没有 transform,输入输出重合,z.infer 看起来「怎么用都对」。

13.2.2 输入输出何时分叉

三类 API 会制造分叉:

import { z } from 'zod';
export const schema = z.object({
  tags: z.string().transform((s) => s.split(',').filter(Boolean)), // ① 转换
  role: z.enum(['admin', 'viewer']).default('viewer'),            // ② 默认值
  bio: z.string().optional(),                                     // ③ 可选
  age: z.coerce.number().int(),                                   // ④ 强制转换
});
用法输入侧输出侧为什么分叉
transformstringstring[]解析过程改变了值的形状
default可选必填缺省值在解析时才补上
optional可选可选两侧一致,不分叉
coerce依版本而定number强制转换放宽了输入约束

transform 与 default 造成的分叉是本质的:"a,b" 和 ["a","b"] 确实是两个不同的值,一个来自输入框,一个发给后端。coerce 的情况更微妙,见 13.2.3。

13.2.3 实测:一个 schema 的两副面孔

把上面这份 schema 交给 tsc 推导,得到的真实结果是(Zod 4):

// z.input<typeof schema> —— 表单里「能填什么」
type FormInput = {
  tags: string;
  role?: 'admin' | 'viewer' | undefined;
  bio?: string | undefined;
  age: unknown;              // 注意这里
};
// z.output<typeof schema> —— 提交给后端「是什么」
type FormOutput = {
  tags: string[];
  role: 'admin' | 'viewer';
  bio?: string | undefined;
  age: number;
};

对比点有三处。tags 从字符串变成了数组;role 从「可不填」变成「一定有值」;而 age 的输入侧是 unknown——这是 Zod 4 的行为:z.coerce.number() 不再假装输入是 number,而是诚实地承认「任何值都可能被塞进来,我在运行时试着转一下」。

同一份 schema 在 Zod 3 下推导出的输入侧是:

type FormInputZod3 = {
  tags: string;
  role?: 'admin' | 'viewer' | undefined;
  bio?: string | undefined;
  age: number;               // Zod 3 仍标注为 number
};

age: number 是不诚实的:输入框给的是字符串 "20",类型却说是 number。这个差异是升级到 Zod 4 时最值得注意的行为变化之一——它把一类「类型说没问题、运行时才炸」的 bug 提前暴露到了类型层。

13.2.4 useForm 的三段泛型

RHF 的 useForm 签名是:

declare function useForm<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues = TFieldValues,
>(props?: UseFormProps<TFieldValues, TContext, TTransformedValues>): UseFormReturn<TFieldValues, TContext, TTransformedValues>;

三个位置参数分别对应:

泛型位置语义应该填
TFieldValues表单内部存的值的类型z.input<S>
TContext传给 resolver 的上下文不用就留默认
TTransformedValueshandleSubmit 回调收到的类型z.output<S>

所以正确的写法是:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
import { schema } from './schema';
type S = typeof schema;
export function useProfileForm() {
  return useForm<z.input<S>, any, z.output<S>>({
    resolver: zodResolver(schema),
    defaultValues: { tags: '', age: 0 },
  });
}

这样 register('tags') 期待的是字符串(用户输入),而 handleSubmit((values) => ...) 里的 values.tags 是 string[](后端要的)。两副面孔各就各位,中间由 Zod 的 parse 完成搬运。

defaultValues 里的 age: 0 也顺理成章:输入侧是 unknown,填 0 合法;如果写成 age: '0' 也不会报错——这正是 unknown 的代价,需要靠 valueAsNumber 或 coerce 在运行时兜住。

13.2.5 写错了会怎样

如果按直觉写成 useForm<z.infer<S>>(等价于输出类型),编译器会给出这样的报错:

error TS2322: Type 'Resolver<{ tags: string; age: unknown; }, any,
{ tags: string[]; age: number; }>' is not assignable to type
'Resolver<{ tags: string[]; age: number; }, any, { tags: string[]; age: number; }>'.
  Types of parameters 'values' and 'values' are incompatible.
    Type '{ tags: string[]; age: number; }' is not assignable to type
    '{ tags: string; age: unknown; }'.
      Types of property 'tags' are incompatible.
        Type 'string[]' is not assignable to type 'string'.

读法是这样的:zodResolver(schema) 返回的 Resolver 第一个参数是输入类型(tags: string),而你告诉 useForm 的 TFieldValues 是输出类型(tags: string[])。两个 Resolver 的第一参数不兼容,于是整行报错。

这段报错很容易看懵,但只要抓住「Resolver 的第一个类型参数永远是输入类型」这一条,就知道该往哪边改:把 TFieldValues 换成 z.input<S>。

反过来说,如果你的 schema 里没有任何 transform / default / coerce,输入输出完全重合,写 useForm<z.infer<S>> 也能过——这正是这个坑潜伏期很长的原因。它会在某天有人给 schema 加了一个 transform 之后突然爆发,而且爆发的文件往往不是他改的那一个。

13.2.6 ZodError 到 FieldErrors 的映射规则

zodResolver 内部做的就是一次结构转换。理解它,才能在需要手写映射(比如服务端错误)时写对。

Zod 失败时抛出的 ZodError 长这样:

{
  issues: [
    { code: 'too_small', path: ['nickname'], message: '昵称至少 2 个字符' },
    { code: 'invalid_string', path: ['email'], message: '邮箱格式不正确' },
    { code: 'custom', path: ['confirm'], message: '两次输入的密码不一致' },
  ]
}

RHF 的 errors 则是一棵按字段名嵌套的对象:

const configExcerpt = {
  nickname: { type: 'too_small', message: '昵称至少 2 个字符' },
  email: { type: 'invalid_string', message: '邮箱格式不正确' },
  confirm: { type: 'custom', message: '两次输入的密码不一致' },
};

映射规则只有两条:

  1. issue.path 数组用 . 连接,得到字段名;
  2. issue.code 落到 error.type,issue.message 落到 error.message。

所以 path: [](根级错误,例如 refine 没给 path)会挂到 errors.root 上,读法是 errors.root?.message。13.1 里反复强调 refine 必须给 path,原因就在这里:不给 path 的错误确实存在,只是你按字段名去找永远找不到。

13.2.7 嵌套与数组字段的路径

path 是一个数组,天然支持任意深度:

schema 结构issue.patherrors 读取路径
z.object({ a: z.object({ b }) })['a', 'b']errors.a?.b?.message
z.array(z.object({ name }))['items', 0, 'name']errors.items?.[0]?.name?.message
z.record(...)['map', 'key1']errors.map?.key1?.message
根级 refine[]errors.root?.message

数组下标是数字,在 errors 里变成数字键。写读取逻辑时要注意:errors.items 是一个数组,元素可能为 undefined,所以必须用可选链逐层访问:

{errors.items?.[index]?.name && (
  <p role="alert">{errors.items[index]?.name?.message}</p>
)}

直接写 errors.items[index].name.message 会在「该行没有错误」时抛 Cannot read properties of undefined——这类崩溃只在部分行有错时出现,测试很容易漏掉。

13.2.8 criteriaMode:一次收集多条错误

默认情况下,一个字段只保留第一条错误。开了 criteriaMode: 'all' 后,同一个字段的多条错误会全部收集:

const form = useForm<z.input<S>, any, z.output<S>>({
  resolver: zodResolver(schema),
  criteriaMode: 'all',
});
// 读取:errors.password?.types 是一个对象
// { too_small: '密码至少 8 位', regex: '需包含数字' }
{errors.password?.types && (
  <ul>
    {Object.values(errors.password.types).map((msg) => (
      <li key={msg}>{msg}</li>
    ))}
  </ul>
)}
criteriaModeerrors.x 结构适合
firstError(默认){ type, message }大多数表单,只提示最该先改的那条
all增加 types: Record<string, string>密码强度这类「一次性列全要求」的场景

注意开启 all 会略微增加每次校验的开销,因为 Zod 需要跑完整个字段的规则链而不是短路返回。

13.2.9 服务端错误的回填

客户端校验永远只是第一道关。真正权威的校验在服务端——邮箱是否已被注册、优惠券是否已过期,这些只有后端知道。所以必须有一条把服务端错误送回表单的通道。

RHF 提供 setError,它的签名与 FieldErrors 同构:

// shouldFocus 是可选布尔配置;下面展示一次调用
setError(name, { type, message }, { shouldFocus: true });

约定一个错误协议,让前后端对得上:

// 后端返回的统一错误结构
export type ApiError = {
  code: 'VALIDATION_FAILED' | 'CONFLICT' | 'UNAUTHENTICATED' | 'RATE_LIMITED' | 'INTERNAL';
  message: string;                                          // 给人看的整体说明
  details?: { path: (string | number)[]; message: string }[]; // 字段级错误
};

回填逻辑:

import type { UseFormSetError } from 'react-hook-form';
export function applyServerErrors<T extends FieldValues>(
  err: unknown,
  setError: UseFormSetError<T>,
) {
  const apiError = toApiError(err); // 把各种异常规整成 ApiError
  if (apiError.code === 'VALIDATION_FAILED' && apiError.details) {
    for (const d of apiError.details) {
      setError(d.path.join('.') as FieldPath<T>, { type: 'server', message: d.message });
    }
    return;
  }
  // 非字段级错误统一挂到 root,由表单顶部横幅展示
  setError('root.server', { type: apiError.code, message: apiError.message });
}

调用点:

const onSubmit = async (values: z.output<S>) => {
  try {
    await api.save(values);
  } catch (e) {
    applyServerErrors(e, setError);
  }
};

setError('root.server', ...) 是 RHF 约定的根级错误位置,读取方式是 errors.root?.server?.message,通常渲染成表单顶部的一条横幅。

d.path.join('.') as FieldPath<T> 里的类型断言很难避免:后端返回的路径是运行时的字符串,编译器无法证明它一定是合法的字段名。但只在这一处断言,并且把它关在一个函数里,比在整个组件里到处 as any 要好得多。

13.2.10 错误协议与展示位置的对应

把 HTTP 状态、错误码与展示位置固定成一张表,前端就有了统一的处理策略:

错误码典型场景展示位置是否重试
VALIDATION_FAILED字段规则不满足各字段下方否
CONFLICT邮箱已注册、用户名占用对应字段下方否
UNAUTHENTICATED登录态失效顶部横幅 + 跳登录否
RATE_LIMITED提交过于频繁顶部横幅 + 倒计时是(退避)
INTERNAL服务端异常顶部横幅 + 上报是(有限次)

这张表的价值在于:错误码决定 UI 形态,而不是在组件里 if (status === 409) 到处判断。新增一种错误时,只需要在这张表里加一行。

类型安全的错误展示组件可以把「路径必须存在」这件事交给编译器:

import { get, type FieldErrors, type FieldPath, type FieldValues } from 'react-hook-form';
export function FieldError<T extends FieldValues>({
  errors,
  name,
}: {
  errors: FieldErrors<T>;
  name: FieldPath<T>;
}) {
  const err = get(errors, name) as { message?: string } | undefined;
  return err?.message ? <p role="alert">{err.message}</p> : null;
}

用的时候 name="email" 会被编译器校验,写错字段名直接报错;get 是 RHF 导出的安全取值工具,能顺着点分路径一路可选链,省掉手写的 ?. 串。

13.2.11 五个常见坑

一、z.infer 当成输入类型。 加了 transform 后 z.infer 是输出类型,register 绑定的字段类型应当是 z.input。判断方法:schema 里搜一遍 transform、default、coerce,有一个就得用三段泛型。

二、refine 不给 path。 错误挂到 errors.root,按字段名找不到,界面一片安静。

三、深层错误路径直接点下去。 errors.items[index].name.message 在部分行无错时崩溃,必须全程可选链或统一用 get。

四、setError 的路径字符串拼错。 setError 是运行时 API,路径写错不会报错,只是错误永远显示不出来。要么用 as const 加 FieldPath 断言,要么把路径集中在常量里。

五、服务端错误覆盖了用户正在改的字段。 回填后如果用户在编辑,错误文案会一直挂着。用 clearErrors(name) 在 onChange 时清掉,或依赖 reValidateMode: 'onChange' 让它被下一次客户端校验自然覆盖。

13.2.12 与其它章节的衔接

表单本身的写法(register / Controller / 校验时机)见 《TypeScript编程实战》13.1 React Hook Form + Zod ;动态字段的路径推导与 useFieldArray 见 《TypeScript编程实战》13.3 复杂表单与动态字段 。服务端一侧的同一份 schema 如何复用见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono) ,端到端省掉手写错误协议见 《TypeScript编程实战》16.1 tRPC 端到端类型安全 ;类型化错误的通用模式见 《TypeScript编程实战》3.1 Result/Either 与类型化错误 。

站内延伸阅读:运行时类型校验与类型安全 、TypeScript 与 Zod 运行时校验 、前端表单与校验架构 、TypeScript 高级类型 、前端 TypeScript 高级类型 。

小结

本节的核心只有一个词:分叉。schema 一旦用了 transform、default 或 coerce,z.input 与 z.output 就不再相等,而 z.infer 站在输出那一侧。表单里能填的是输入类型,发给后端的是输出类型,useForm<z.input<S>, any, z.output<S>> 的三段泛型就是把两者各就各位的写法。写错时的报错看着吓人,读法却很简单:Resolver 的第一个类型参数永远是输入类型。

错误映射这边,规则同样简洁:issue.path 用 . 连接成字段名,issue.code 落到 error.type,issue.message 落到 error.message;path 为空则挂到 root。嵌套与数组路径必须全程可选链或交给 get。服务端错误用 setError 回填,字段级的按 path 落到输入框,其余挂到 root.server 由顶部横幅统一展示,错误码与展示位置的对应关系固定成一张表。

下一节 《TypeScript编程实战》13.3 复杂表单与动态字段 会把这些规则推向更复杂的地形:字段会增删、会嵌套、会按另一个字段的值条件出现,路径也从静态字符串变成需要推导的模板类型。

阅读导航:上一节:13.1 React Hook Form + Zod · 下一节:13.3 复杂表单与动态字段 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes