React 表单状态的类型安全工程:从 any 逃逸到 Zod 编译期契约
在 React 应用里,表单是运行时错误的重灾区:字段名拼错、空值逃逸、as any 断言横行。本文不从"怎么写表单"讲起,而是聚焦一个更隐蔽的问题——表单的状态与校验,为什么总是逃出 TypeScript 的掌控,以及如何用类型系统把它在编译期锁死。
受控组件的第一个裂缝:string | undefined
最朴素的受控表单长这样:
interface FormState {
username: string;
age: number;
email: string;
}
const [form, setForm] = useState<FormState>({
username: "",
age: 0,
email: "",
});
age 用 0 兜底看似安全,但语义已经错位:0 代表"用户没填",还是"用户填了 0 岁"?一旦输入框清空,e.target.value 永远是 string,Number("") 得到 NaN,而 NaN 能安然穿过 age: number 的类型检查。
// 反例:类型说 number,运行时却可能是 NaN
setForm({ ...form, age: Number(e.target.value) });
form.age.toFixed(2); // NaN.toFixed 不报错,但输出 "NaN"
这就是"类型安全"的假象——类型系统挡住的是 string 塞进 number,却挡不住 NaN 这个合法的 number 值。真正的契约应该是:未填写的字段在类型层就是 undefined。
// 正例:让空态在类型上显式存在
interface FormState {
username: string;
age: number | undefined;
email: string;
}
用 Discriminated Union 消灭"不可能的校验态"
校验状态是表单状态机的典型场景。常见反例是把所有可能塞进一个扁平对象:
// 反例:非法状态可以被构造出来
interface Validation {
status: "idle" | "validating" | "valid" | "error";
message?: string; // 只有 error 时才有意义,但任何状态都能带上
field?: string; // 所有状态都能带 field,语义混乱
}
这个类型允许 { status: "valid", message: "出错了" } 这样逻辑上不可能的组合。用可辨识联合把一个"扁对象"变成互斥的状态:
// 正例:每种状态各持所需字段,非法态在编译期无法表达
type Validation =
| { status: "idle" }
| { status: "validating" }
| { status: "valid" }
| { status: "error"; message: string; field: keyof FormState };
// TS 会逼你在消费端做穷尽处理
function renderError(v: Validation) {
switch (v.status) {
case "error":
return `字段 ${v.field} 出错:${v.message}`;
case "idle":
case "validating":
case "valid":
return null;
}
}
field: keyof FormState 这行是点睛之笔:错误字段名被约束为 username | age | email,拼成 "userName" 会直接编译失败,而不是等到运行时才发现错误信息永远不显示。
Zod 把运行时校验"编译"进类型
手写 validator 函数时,类型与校验逻辑是两套平行、靠人工保证同步的代码。Zod 的核心理念是单一数据源——用 schema 驱动校验,再用 z.infer 反推出类型:
import { z } from "zod";
const PasswordSchema = z
.string()
.min(8, "至少 8 位")
.regex(/[A-Z]/, "需包含大写字母");
const SignupSchema = z.object({
username: z.string().min(3).max(20),
password: PasswordSchema,
email: z.string().email(),
});
// 类型从 schema 派生,永远不会和校验逻辑失步
type SignupInput = z.infer<typeof SignupSchema>;
function submit(raw: unknown) {
const result = SignupSchema.safeParse(raw);
if (!result.success) {
// result.error.issues 里的 path 是精确到字段的
return { ok: false, errors: result.error.flatten() };
}
// 这里 result.data: SignupInput,类型已收窄且保证合法
return { ok: true, data: result.data };
}
关键区别:入口参数是 unknown 而不是 FormState。因为表单提交的数据来自 FormData,类型上本就该视为不可信输入,unknown 逼迫你先校验再使用,从根上杜绝"以为类型对、其实没校验"的自欺。
让校验缺陷在 CI 门前显形
光有 z.infer 还不够,还需要一层反向约束:如果你改了 schema,忘记同步某个消费方,编译要能撞出来。
// 契约测试:用 type-level assertion 锁死 schema 与视图字段的一致性
type FieldName = keyof SignupInput;
// 视图层的字段渲染必须遍历所有 schema 字段
const fieldOrder: FieldName[] = ["username", "email", "password"];
// 若漏掉一个字段,用下面这行让 TS 报警:
type AssertEqual<T extends readonly FieldName[]> = T extends readonly [
...FieldName,
...infer _Rest
]
? never
: T;
更工程化的做法,是用 satisfies 建立"字段清单 = schema 字段合集"的常量契约:
const REQUIRED_FIELDS = Object.keys(SignupSchema.shape) as (keyof SignupInput)[];
// 新增一个字段时,如果渲染层没引用,用 noUnusedLocals + 穷举类型兜底
小结
表单类型安全的三个层次,从表及里:
- 让空态显式化:
number | undefined替换0兜底,NaN不再伪装成合法值。 - 用可辨识联合建模状态机:合法状态压缩进类型,非法状态在编译期无法表达。
- Zod 单一数据源:schema 驱动校验,
z.infer派生类型,unknown收尾,消灭"平行但失步"的校验代码。
大多数表单 bug 不是逻辑写错,而是类型表达了错误的允许态。把校验前移进类型层,你拦截的不是几行拼写错误,而是一整类"运行时才炸"的架构性裂缝。
评论区
登录 后参与评论