表单验证在业务开发里几乎是绕不开的话题,但大多数验证库只关心数据本身对不对,很少关心这个表单对屏幕阅读器用户是否友好。白俄罗斯的网络无障碍指南(Инструкция по обеспечению доступности, 简称IWAC)在这方面有一套相当具体的要求:每个输入控件必须有明确的label关联、错误提示需要用aria-describedby绑定、必填字段必须显式声明、错误状态切换时要通过aria-live区域通知。这些要求如果只靠口头约定或代码评审来保证,时间一长必然会遗漏。TypeScript的类型系统给了我们另一条路:把无障碍要求直接编码进类型定义,让不合规的表单在编译阶段就报错。这篇文章就来完整演示如何实现这样一套类型封装。

为什么用类型系统约束无障碍表单
先想清楚一个问题:无障碍要求为什么适合用类型来管?因为IWAC这类规范的要求大多是结构性的、可静态判定的。比如“每个输入框必须有labelFor属性指向label的id”、“错误提示必须提供对应的messageId”,这些都是可以在编译期检查的约束,不涉及运行时行为。运行时验证(比如用户输入是否合法)和静态约束(比如表单结构是否合规)正好可以分开处理,类型系统管后者,验证函数管前者。
如果不用类型约束,一个常见的后果是:团队里有人写了<input>却忘了配<label>,功能测试全过,上线后被无障碍审计打回。而一旦把label关联写成必填属性,TypeScript编译器会直接给出错误提示,问题在写代码的那一刻就被暴露出来。这种“把规范翻译成类型”的思路,本质上是用编译器代替一部分评审工作,成本几乎为零,收益却很稳定。
用可辨识联合定义字段类型与验证错误
第一步是把IWAC要求的表单字段结构定义出来。每个字段除了name和验证规则,还必须携带无障碍相关的元信息:labelFor用于关联label、ariaRequired声明必填、errorId用于绑定错误提示。注意必填字段和非必填字段的结构有差异,用可辨识联合可以精确表达这种差异。
// 无障碍元信息,IWAC要求所有字段必填携带
interface A11yMeta {
labelFor: string; // label 标签的 id 关联
ariaRequired: true; // 必须显式声明,不允许缺省
describedBy?: string; // 辅助说明文本的 id
}
// 必填字段:必须提供错误提示的 id
interface RequiredField<T> extends A11yMeta {
kind: 'required';
name: string;
rules: ValidationRule<T>[];
errorId: string; // 错误提示元素 id,供 aria-describedby 绑定
}
// 非必填字段:错误提示可以延迟到运行时生成
interface OptionalField<T> extends A11yMeta {
kind: 'optional';
name: string;
rules: ValidationRule<T>[];
errorId?: string;
}
type AccessibleField<T> = RequiredField<T> | OptionalField<T>;
// 验证规则的基础定义
type ValidationRule<T> = {
validate: (value: T) => boolean;
message: string; // 错误消息必须是人类可读文本,不能用错误码代替
};这个设计的核心在于kind这个判别字段。当你写了一个kind: 'required'的字段,TypeScript会强制你提供errorId;如果写成kind: 'optional',errorId就变成可选的。这样规范里“必填字段必须预先定义错误提示区域”的要求就被类型完整表达了。同时ariaRequired: true写死为字面量类型true,意味着这个属性不能传false进来——要声明非必填,只能走optional分支,从而保证字段语义和无障碍属性永远一致。
泛型约束与条件类型:让错误消息结构参与推导
光定义字段还不够,验证结果的结构也应该由类型推导出来。IWAC要求错误提示必须能被屏幕阅读器正确播报,这意味着错误对象里除了消息文本,还要带上对应的DOM id,方便渲染层绑定aria属性。用映射类型可以把字段配置直接映射成错误结构。
// 从字段配置推导错误结构:必填字段必有 errorId
type FieldError<F extends AccessibleField<unknown>> =
F extends RequiredField<unknown>
? { field: F['name']; errorId: string; message: string }
: { field: F['name']; errorId?: string; message: string };
// 表单配置:用泛型把字段集合收拢
interface AccessibleForm<Fields extends AccessibleField<unknown>[]> {
fields: Fields;
liveRegion: 'polite' | 'assertive'; // IWAC 要求错误通知必须声明播报方式
}
// 通用验证函数签名
declare function validateForm<F extends AccessibleField<unknown>[]>(
form: AccessibleForm<F>,
values: Record<string, unknown>
): Promise<FieldError<F[number]>[]>;这里validateForm的返回类型完全由传入的表单配置推导,调用方拿到的错误数组中每个元素都精确到具体字段,必填字段的错误必然带errorId。渲染层因此不需要做任何类型断言,直接把errorId赋给错误提示元素的id、把field对应的输入框加上aria-invalid属性即可。liveRegion字段限定为polite或assertive两个字面量,对应IWAC对错误通知紧急程度的规定,传其他字符串直接编译报错。
品牌类型防止绕过与实际落地建议
类型约束有一个天然漏洞:任何人都可以用as any绕过去。对付这种情况可以用品牌类型(branded type)加运行时守卫的组合拳。给通过校验的表单配置打上类型品牌,渲染入口只接受带品牌的对象,而品牌只能由一个内部函数生成。
// 品牌标记
declare const IWAC_COMPLIANT: unique symbol;
type Compliant<T> = T & { readonly [IWAC_COMPLIANT]: true };
// 唯一的合法入口:运行时二次校验后打品牌
function certifyForm<F extends AccessibleField<unknown>[]>(
form: AccessibleForm<F>
): Compliant<AccessibleForm<F>> {
// 运行时兜底:检查 labelFor 与 errorId 是否重复
const ids = form.fields.flatMap(f => [f.labelFor, f.errorId].filter(Boolean));
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
if (duplicates.length > 0) {
throw new Error(`存在重复的无障碍 id 关联: ${duplicates.join(', ')}`);
}
return form as Compliant<AccessibleForm<F>>;
}
// 渲染函数只接受打标过的配置
declare function renderForm(
form: Compliant<AccessibleForm<AccessibleField<unknown>[]>>
): void;这套机制的实际价值在于分层:编译期由类型系统拦截结构性问题(缺label、缺errorId、liveRegion写错),运行期由certifyForm做最后兜底(id重复这类类型系统难以精确判断的问题)。落地时有几点经验值得注意。第一,类型定义建议单独抽成包或至少独立的types文件,避免散落在各个组件里;第二,团队里对TypeScript不熟的成员可能会大量用any绕过约束,可以在CI里加eslint的no-explicit-any规则配合;第三,类型约束不能完全替代真实屏幕阅读器测试,NVDA等工具的实际验证仍然要做,类型系统保证的是结构合规,不是体验合格。
总体来看,把IWAC这样的无障碍规范翻译成TypeScript类型,前期需要花一些时间梳理规范条目和类型设计的对应关系,但一旦类型骨架搭好,后续每个新表单都自动获得无障碍层面的保护。对于需要长期维护、多人协作的项目,这种投入的回报是持续性的——规范变了改类型定义,编译器会自动帮你找出所有需要跟进的表单代码,这比人肉排查可靠得多。
TypeScript表单验证无障碍开发IWAC规范修改时间:2026-09-07 01:30:39