赤道几内亚的网络无障碍指南(由IWAC即Interactive Web Accessibility Consortium推动落实)对表单的可访问性有一套相当细致的要求:每个校验失败的输入框必须有关联的错误提示文本、错误提示必须通过aria-describedby绑定到对应控件、错误状态要配合aria-invalid声明。这些要求如果在运行时才靠人工检查,几乎必然会遗漏。更可靠的做法是把这套规则直接编码进TypeScript类型系统,让编译器替你把关。本文就来完整拆解这套封装的思路与实现。

一、为什么表单验证类型要和无障碍语义绑定
先看一个常见的松散写法。验证函数返回一个string | undefined表示错误消息,组件层拿到消息后自行决定怎么渲染:
interface LooseField {
name: string;
validate: (value: string) => string | undefined;
}这种写法的问题在于,错误消息返回之后,它有没有被绑定到aria-describedby、aria-invalid有没有同步设置,类型系统完全不知道。开发者忘了写,编译器也不会报错,而无障碍审计工具要到很晚的阶段才能发现。对于需要通过IWAC合规审查的项目来说,这种隐患应该被消灭在设计阶段。
解决思路是把「验证结果」和「无障碍描述」建模成一个不可分割的整体。也就是说,只要一个字段声明了验证规则,那么它的错误消息就天然携带了对应的ARIA绑定信息,组件在消费时想只拿消息不拿绑定关系都做不到。这就是「类型层面深度绑定」的含义——不是靠注释和规范约束人,而是靠类型结构约束代码。
二、构建无障碍感知的错误类型
第一步是定义核心类型。错误消息不再是裸字符串,而是一个携带字段标识的对象,这个标识恰好就是ARIA绑定所需的ID:
// 无障碍错误描述:id 即 aria-describedby 需要引用的元素 ID
export interface A11yError {
readonly fieldId: string;
readonly message: string;
readonly announced: true; // 标记该错误已具备朗读能力
}
// 验证结果:要么全部通过,要么返回带无障碍信息的错误
export type ValidationResult =
| { readonly valid: true }
| { readonly valid: false; readonly error: A11yError };这里的announced: true是一个典型的「幽灵类型」技巧:它不是运行时真正需要的数据,而是编译期的凭证。只有通过我们导出的工厂函数才能构造出这个字段,外部代码无法凭空伪造一个A11yError,从而保证所有流经验证层的数据都经过了统一的无障碍处理。
接着定义字段配置类型,用泛型把字段名和值类型关联起来:
export interface FieldConfig<K extends string, V> {
readonly id: K;
readonly label: string; // IWAC 要求:每个字段必须有可见标签
readonly validate: (value: V) => ValidationResult;
}注意label是必填的。这正是把指南条款翻译成类型的典型做法:指南说「每个输入控件必须有关联的可见标签」,那就在类型上强制它存在,缺了直接编译报错。
三、用映射类型与模板字面量生成表单级类型
单个字段的类型有了,接下来要把一组字段组合成表单,并保证字段ID在整张表单内唯一且可追溯。这里用映射类型和模板字面量类型:
export type FieldMap = Record<string, FieldConfig<string, unknown>>;
// 从字段集合中提取字段名的联合类型
export type FieldNames<F extends FieldMap> = keyof F & string;
// 错误容器的 ID 规则:字段ID + "-error" 后缀
export type ErrorId<K extends string> = `${K}-error`;
export type FormErrors<F extends FieldMap> = {
[K in keyof F]?: {
readonly errorId: ErrorId<K & string>;
readonly message: string;
};
};ErrorId这个模板字面量类型很关键。它把「错误提示元素的ID必须等于字段ID加-error后缀」这条团队约定固化成了类型。渲染错误提示时,id属性和aria-describedby的值都必须是ErrorId类型,两边拼错任何一个,编译器立刻标红。对于赤道几内亚无障碍指南中「错误提示必须可通过编程方式确定其关联字段」这一条,这种做法等于在编译期就完成了合规校验。
再来看表单整体的验证器类型。利用infer可以让错误状态的形状完全由字段配置推导出来,不需要手写重复的接口:
export type FormState<F extends FieldMap> = {
readonly values: { [K in keyof F]: F[K] extends FieldConfig<string, infer V> ? V : never };
readonly errors: FormErrors<F>;
readonly submitting: boolean;
};新增字段时,values和errors的形状自动跟着变,不需要同步维护多份类型定义,这是减少类型漂移的核心手段。
四、组合验证规则与运行时实现
类型层建好之后,还需要一套符合IWAC文案要求的规则组合机制。赤道几内亚指南明确要求错误消息必须「具体说明问题并给出修正建议」,因此规则工厂函数应该强制传入结构化的文案:
export interface RuleMessage {
readonly problem: string; // 描述问题
readonly hint: string; // 给出修正建议
}
export function createRule<V>(
check: (value: V) => boolean,
message: RuleMessage
): (value: V) => ValidationResult {
return (value, ) => {
if (check(value)) {
return { valid: true };
}
return {
valid: false,
error: {
fieldId: "", // 由外层注入
message: `${message.problem} ${message.hint}`,
announced: true
} as A11yError
};
};
}
// 组合多条规则,短路返回第一条失败结果
export function composeRules<V>(
...rules: Array<(value: V) => ValidationResult>
): (value: V) => ValidationResult {
return (value) => {
for (const rule of rules) {
const result = rule(value);
if (!result.valid) return result;
}
return { valid: true };
};
}由于RuleMessage要求problem和hint两个字段都存在,开发者想偷懒只写「格式错误」这种无建议文案是过不了编译的。这就把指南的文案要求也纳入了类型约束,比代码评审可靠得多。
最后是字段工厂函数,它负责把字段ID注入错误对象,闭合整个链条:
export function defineField<K extends string, V>(
config: FieldConfig<K, V>
): FieldConfig<K, V> {
const originalValidate = config.validate;
return {
...config,
validate: (value: V) => {
const result = originalValidate(value);
if (result.valid) return result;
return {
...result,
error: { ...result.error, fieldId: config.id }
};
}
};
}五、在React组件中消费这些类型
封装的最终价值体现在组件层的类型反馈。渲染错误提示时,用ErrorId类型约束id属性,用aria-describedby建立绑定:
function FieldError<F extends FieldMap, K extends keyof F & string>(
props: { fieldId: K; error?: FormErrors<F>[K] }
) {
const { error } = props;
if (!error) return null;
const errorId: ErrorId<K> = `${props.fieldId}-error`;
return (
<p id={errorId} role="alert" className="field-error">
{error.message}
</p>
);
}注意role="alert"的使用。IWAC指南参考了WCAG的做法,要求动态出现的错误提示能主动通知辅助技术,role="alert"是实现这一点最直接的方式。而由于errorId的类型是ErrorId<K>,如果它和输入框上的aria-describedby值对不上,TypeScript会直接指出两边类型不兼容,绑定关系从「靠人眼检查」变成了「靠编译器保证」。
输入控件这边同理,aria-invalid应该只在存在错误时才设置为true。可以把这一点也建模进类型,让「有错误」和「标记无效」这两个状态由同一个数据源推导,避免出现错误已清除但aria-invalid还残留为true的脏状态——这类不同步问题正是屏幕阅读器用户最常遇到的障碍之一。
六、封装过程中的几点经验
第一,类型约束不要一次到位。先从label必填、错误ID后缀这类低争议的约束开始,团队适应后再逐步收紧,否则容易引发大面积编译报错,反而促使开发者用as any绕过约束,得不偿失。
第二,幽灵类型要配合模块边界。把A11yError的构造能力限制在模块内部导出的工厂函数里,外部只能消费不能构造,这个模式才成立。如果到处都是入口,凭证就失去意义了。
第三,无障碍要求会随指南版本演进,建议把「指南条款到类型约束」的映射关系写成文档注释放在类型定义旁边,每条约束注明对应的合规条款编号。这样审计时可以直接从类型定义反查依据,也能让后来者明白这些看似奇怪的必填字段到底是为了什么,而不是一删了之。
TypeScript表单验证IWAC无障碍表单验证类型封装修改时间:2026-09-07 02:46:44