在 IWAC 这类内部组件封装库里,无障碍指南相关能力通常不会单独建一套类型体系,而是散落在表单校验、组件属性和文档说明中。要让非网络无障碍指南中的表单验证部分可维护、可复用,第一件事不是继续堆函数,而是先把类型定义成一张清晰的网:哪些字段需要校验、一条规则包含哪些参数、错误信息如何与字段对应。借助 TypeScript 的字面量联合、泛型约束和映射类型,可以把这些规则从口头约定变成编译期检查。

把指南条目拆成可组合的规则类型
非网络无障碍指南里的表单验证通常不是单一必填校验,它包含标记关联、键盘可达、错误提示朗读、焦点顺序等更细的检查项。落到表单字段上,可以抽象为几类基础规则:必填、最小长度、最大长度、正则匹配以及自定义校验。先不要急着写 validate 函数,而是用类型把这些规则参数固定下来。这样做的好处是,任何人在配置表单时都能从 IDE 的自动补全里看到某个规则需要哪些字段,漏传或错传参数会直接标红。
下面这个示例先定义了校验上下文和基础规则接口。每条规则都带一个 type 字段用于区分,同时保留可选的 message 用于覆盖默认错误提示。注意 minLength 规则要求 min 参数,pattern 规则要求 pattern 参数,而 required 不需要额外参数。类型定义让这些差异在编译期就可见。
interface ValidationContext {
field: string;
value: unknown;
form: Record<string, unknown>;
}
type RuleType = 'required' | 'minLength' | 'maxLength' | 'pattern' | 'custom';
interface BaseRule {
type: RuleType;
message?: string;
}
interface RequiredRule extends BaseRule {
type: 'required';
}
interface MinLengthRule extends BaseRule {
type: 'minLength';
min: number;
}
interface MaxLengthRule extends BaseRule {
type: 'maxLength';
max: number;
}
interface PatternRule extends BaseRule {
type: 'pattern';
pattern: RegExp;
}
interface CustomRule<T = unknown> extends BaseRule {
type: 'custom';
validator: (value: T, ctx: ValidationContext) => boolean;
}
type FieldRule<T = unknown> = RequiredRule | MinLengthRule | MaxLengthRule | PatternRule | CustomRule<T>;
把规则拆成接口而不是用一个带可选属性的宽泛对象,是这层类型定义的第一个关键点。宽泛对象会让你写出 if (rule.min !== undefined) 这类运行时代码,类型系统无法确认规则与参数是否匹配。而接口继承加字面量联合能让 TypeScript 自动收窄:当你判断 rule.type === 'minLength' 时,rule.min 会被识别为 number,不需要手动断言。
另外,这里把 FieldRule 定义为联合类型,而不是一个包含全部属性的接口,主要是为了让非法配置在声明阶段就失败。比如给 required 规则塞一个 min 字段,或者给 minLength 规则漏掉 min,TypeScript 都会报错。这种约束在下游被大量使用时,能显著降低配置错误率。
用判别联合表达跨字段与条件规则
现实中的表单校验很少只依赖单个字段,很多非网络无障碍指南的检查项会要求跨字段联动。例如,勾选某选项后才要求填写额外说明,或者字段 A 的值大于字段 B 时才需要做格式校验。这类规则如果继续用 FieldRule 描述会很别扭,因为校验逻辑需要访问整个表单对象,而不是只接收当前字段值。此时可以引入条件规则,把“何时启用哪条规则”也纳入类型系统。
条件规则的核心是 when 函数,它接收整个表单对象并返回布尔值,然后通过 then 和可选的 otherwise 指定走哪条实际规则。这样配置层可以声明式地表达依赖关系,而不用在校验函数里写一堆 if 分支。下面是条件规则的类型定义:
interface ConditionalRule<T = unknown> {
type: 'conditional';
when: (form: Record<string, unknown>) => boolean;
then: FieldRule<T>;
otherwise?: FieldRule<T>;
}
type FullRule<T = unknown> = FieldRule<T> | ConditionalRule<T>;
把 FullRule 作为表单字段配置的最终规则类型之后,单字段规则和跨字段规则可以在同一个数组或对象里混用。判别联合的优点是,后续校验器在遍历规则时可以根据 type 字段精确分流:遇到 conditional 先执行 when,再根据返回值选择执行 then 还是 otherwise;遇到基础规则则直接交给对应的校验函数。类型系统会确保每个分支拿到的参数都是完整的。
还需要注意 then 和 otherwise 都被限定为 FieldRule<T>,而不是 FullRule<T>。这样避免了无限嵌套的条件规则,让配置结构保持在两层以内。对于大多数表单场景来说,两层条件已经足够表达常见的联动逻辑,同时也能防止类型定义变得过度复杂。
用映射类型约束字段并生成错误对象
表单校验的配置最终要以字段名为键,绑定到具体的数据模型上。如果只把规则配置定义成 Record<string, FullRule>,虽然简单,但会丢失字段名与数据类型之间的对应关系。比如数据模型里 age 是 number,配置里却可能写入一个针对字符串的 minLength 规则。要让类型系统拦住这种错误,需要把表单数据模型作为泛型传入,并用映射类型遍历所有字段。
下面的 FormSchema 类型要求传入的键必须与数据模型完全一致,每个字段的规则类型会根据该字段的值类型自动收窄。随后 ErrorMap 类型同样使用映射类型,为每个字段生成一个可选字符串数组,表示该字段可能出现的错误信息。这样校验函数的返回类型就与输入结构保持同步。
type FormSchema<T extends Record<string, unknown>> = {
[K in keyof T]: FullRule<T[K]>;
};
type ErrorMap<T extends Record<string, unknown>> = {
[K in keyof T]?: string[];
};
function validateForm<T extends Record<string, unknown>>(
schema: FormSchema<T>,
values: T
): ErrorMap<T> {
const errors: ErrorMap<T> = {};
// 实际校验时遍历 schema,将每个字段的错误信息写入 errors
return errors;
}
这里的关键在于 keyof T 会提取数据模型中的所有字段名,而 FullRule<T[K]> 会把当前字段的值类型传递到规则泛型里。比如 age 字段的值类型是 number,那么它的规则就会被约束为 FullRule<number>。基础规则中的 minLength 和 pattern 仍然可以出现,因为目前还没有在类型层面排除不适用于数字的规则。下一步可以通过更精细的规则标签来限制这一点。
如果你希望进一步区分字符串规则和数值规则,可以把 MinLengthRule、MaxLengthRule 和 PatternRule 的泛型定义为只接受 string,而数值字段则只能使用 min、max 等数值比较规则。这样 FormSchema<UserForm> 中给 age 配置 minLength 就会直接编译报错。这个策略虽然会增加一些类型定义量,但对大型表单库来说非常值得。
类型级测试与工程落地建议
类型定义写完并不代表结束,还要保证在后续重构中不会退化。对于公共的类型包,可以使用 tsd 或 vitest 的类型测试能力,编写专门检查类型的用例。比如用 @ts-expect-error 注释验证某个错误配置确实会被编译器拒绝。如果某天有人把 FullRule 改宽了,导致原本应该报错的地方不再报错,@ts-expect-error 会因为下方没有错误而自身报错,从而让类型测试失败。
下面这个例子同时包含了合法配置和非法配置。合法配置中 age 使用自定义校验函数,类型可以正确推断参数类型;非法配置给 age 设置了字符串专用的 minLength 规则,编译期会报错。保留这类测试用例,能让类型定义在多人协作中保持刚性。
type UserForm = {
name: string;
age: number;
};
const validSchema: FormSchema<UserForm> = {
name: { type: 'required' },
age: { type: 'custom', validator: (value) => typeof value === 'number' }
};
// 错误示例:age 是 number,不能应用字符串长度规则
// @ts-expect-error
const invalidSchema: FormSchema<UserForm> = {
name: { type: 'required' },
age: { type: 'minLength', min: 3 }
};
除了类型测试,导出公共 API 时还需要注意文档与类型同步。为每个规则接口补充 JSDoc 注释,并在注释中说明该规则对应哪些无障碍检查项,可以让使用者在 IDE 悬停时快速理解意图。类型定义最终是给开发者看的,好的命名和注释能减少一半的沟通成本。比如把 RequiredRule 命名为 RequiredAccessibleRule,或者增加 guideline?: string 字段来记录来源条目,都是可选但实用的做法。
最后,不要为了类型完美而牺牲可读性。IWAC 封装的表单验证类型定义,目的是在不影响业务开发效率的前提下,把非网络无障碍指南的关键约束固化下来。先覆盖最常用的规则,再逐步补充边界情况,比一次性设计出几十个类型参数更实际。类型系统是工具,不是目的。

