沙特阿拉伯的通信与信息技术委员会(CITC)发布的IWAC(Web Accessibility Framework)无障碍指南,对表单的可访问性提出了相当细致的要求:每个输入控件必须有明确的关联标签、错误状态必须以文本形式呈现给屏幕阅读器、必填字段需要显式标注、分组控件必须使用fieldset语义等。这些要求在传统开发流程里通常靠Code Review和手动测试来保障,但人的检查总是有遗漏的。如果把IWAC的表单要求直接编码进TypeScript的类型系统,编译器就能在代码编写阶段拦截掉大部分不合规的写法。本文介绍一套完整的封装思路。

为什么用类型系统约束无障碍实现
无障碍问题的最大特点是它们属于结构层面而非逻辑层面的缺陷。一个<input>缺少aria-label、错误消息没有通过aria-describedby绑定到输入框,这些错误不会导致运行时崩溃,测试用例也大概率全绿,但对屏幕阅读器用户来说是致命的。这类结构性缺陷恰好是TypeScript最擅长捕捉的目标。
用类型约束的另一个好处是文档化。当团队新成员调用表单组件时,如果类型定义要求必须传入错误消息的id、必须区分错误级别,他不需要去翻IWAC文档就能写出基本合规的代码。类型定义本身成了一份可执行的规范副本,规范更新时同步修改类型文件即可,全项目立刻感知到变化。
需要注意的一点是,类型检查不能覆盖所有场景。比如颜色对比度、焦点顺序这类视觉层面的要求,类型系统无能为力,仍需配合自动化扫描工具和人工测试。类型化封装的目标是把可结构化的那部分要求全部固化下来。
建模IWAC表单核心要求的类型定义
IWAC基于WCAG 2.1级别AA,表单相关条款可以归纳为四个维度:标签关联、错误提示、必填标识和分组语义。先定义一个基础字段类型,把标签作为强制属性:
// 表单字段的基础类型,label为必填项,对应IWAC对标签关联的强制要求
interface AccessibleFieldBase {
/** 字段唯一标识,用于绑定label与控件 */
id: string;
/** 可访问名称,缺少此项的表单控件不符合IWAC 3.3.2的要求 */
label: string;
/** 是否必填,必填时组件必须渲染aria-required */
required: boolean;
/** 关联的帮助文本id,可选 */
helpTextId?: string;
}接着用可辨识联合类型描述验证状态。错误状态必须携带可被屏幕阅读器朗读的消息文本,并且错误消息需要一个稳定的id,方便绑定到aria-describedby:
// 验证状态的可辨识联合,status作为判别字段
type FieldValidationState =
| { status: 'idle' }
| { status: 'validating' }
| {
status: 'error';
/** 错误消息文本,IWAC要求以文本形式呈现,不能仅靠颜色 */
message: string;
/** 错误消息元素的id,供aria-describedby引用 */
errorId: string;
/** 错误级别决定aria-live的设置策略 */
severity: 'error' | 'warning';
}
| { status: 'success' };这里的关键设计是:当status为error时,message和errorId变成必填属性。如果开发者只写了status: 'error'而遗漏消息文本,编译器会直接报错。这比口头约定可靠得多。
对于不同控件类型,再扩展一个泛型的字段类型。单选框和复选框组必须使用fieldset与legend语义,这一点通过让group控件强制携带legend属性来实现:
type FieldType = 'text' | 'email' | 'select' | 'radio-group' | 'checkbox-group';
interface AccessibleField<T extends FieldType> extends AccessibleFieldBase {
type: T;
validation: FieldValidationState;
}
// 条件类型:分组控件必须有legend,单值控件必须有占位符语义描述
type ConstrainedField<T extends FieldType> = T extends 'radio-group' | 'checkbox-group'
? AccessibleField<T> & { legend: string }
: AccessibleField<T> & { placeholder: string };封装表单级别的类型工具
单个字段的类型定义到位后,还需要一个表单级别的组装器。IWAC要求提交失败时错误摘要必须获得焦点,且每个错误项可跳转到对应字段,所以表单级别的类型需要强制包含错误摘要的相关配置:
interface AccessibleForm<T extends Record<string, FieldType>> {
/** 表单内所有字段,键名即字段名 */
fields: { [K in keyof T]: ConstrainedField<T[K]> };
/** 提交失败时的错误摘要配置,对应IWAC 3.3.1纠错要求 */
errorSummary: {
/** 摘要容器的id */
summaryId: string;
/** 标题文本,例如“表单提交存在以下问题” */
heading: string;
/** 提交失败后是否自动聚焦摘要 */
focusOnSubmit: true;
};
onSubmit: (values: FormValues<T>) => Promise<void>;
}
// 从字段定义推导出值类型,避免手写重复接口
type FormValues<T extends Record<string, FieldType>> = {
[K in keyof T]: T[K] extends 'radio-group' ? string : T[K] extends 'checkbox-group' ? string[] : string;
};这个设计里有几个细节值得展开。首先是focusOnSubmit: true写成字面量类型而非boolean,意味着这个配置不可省略也不可关闭,因为IWAC对错误摘要聚焦是硬性要求,不允许业务方随意关掉。其次是FormValues利用映射类型和条件类型自动推导表单值的结构,checkbox-group映射为字符串数组,其余映射为字符串,调用方不需要重复维护两份接口。
错误提示的动态更新也是一个典型场景。屏幕阅读器需要感知到错误消息的出现,因此错误区域应该使用aria-live区域。可以再封装一个工具类型,约束实时提示组件的属性:
interface LiveRegionProps {
/** 实时区域的礼貌程度,错误类消息用assertive */
politeness: 'polite' | 'assertive';
/** 提示文本,不能为空字符串 */
message: NonEmptyString;
}
// 利用模板字面量类型确保字符串非空
type NonEmptyString = string & { __brand: 'non-empty' };这里用了品牌类型技巧,让message在类型层面排除空字符串。实际项目中可以提供一个带运行时校验的构造函数来产出这个类型,把编译期检查和运行时兜底结合起来。
在React组件中落地这套类型
类型定义最终要服务于组件。以一个符合IWAC要求的输入组件为例,展示类型如何驱动实现:
function AccessibleInput({ field }: { field: ConstrainedField<'text'> }) {
const describedBy =
field.validation.status === 'error'
? field.validation.errorId
: field.helpTextId;
return (
<div>
<label htmlFor={field.id}>
{field.label}
{field.required && <span aria-hidden="true">*</span>}
</label>
<input
id={field.id}
required={field.required}
aria-required={field.required}
aria-describedby={describedBy}
aria-invalid={field.validation.status === 'error'}
/>
{field.validation.status === 'error' && (
<p id={field.validation.errorId} role="alert">
{field.validation.message}
</p>
)}
</div>
);
}注意组件内部的所有aria属性绑定都直接取自类型定义中的字段,不存在临时拼接的id。这种数据驱动的绑定方式保证了label、控件、错误消息三者之间的关联永远一致。当validation处于error状态时,TypeScript的类型收窄让field.validation.errorId可以直接访问而不需要非空断言,这正是可辨识联合带来的好处。
在Vue或Angular项目中,同样的类型文件可以直接复用,只需在模板绑定层做对应适配。类型定义本身与框架无关,这也是把无障碍约束沉淀在类型层而非组件层的好处之一——换框架时规范资产不会流失。
类型化封装的边界与补充手段
最后要诚实面对类型方案的局限。TypeScript在编译后被完全擦除,运行时的动态行为(比如用户通过DOM操作移除了aria属性、第三方库注入了不符合规范的标记)无法被类型系统覆盖。针对这些残留风险,建议做三层防线:编译期用本文的类型定义拦截,构建期用axe-core或Lighthouse做自动化扫描,发布前用NVDA或VoiceOver做人工抽测。
类型定义的维护成本也需要控制。IWAC指南会随WCAG的演进而更新,建议把每个类型属性与具体条款编号的对应关系写在注释里,就像前文代码中标注的IWAC 3.3.1、3.3.2那样。规范变更时,通过全局搜索条款编号就能快速定位所有受影响的类型属性,这个小小的习惯能让合规维护的成本降低一个数量级。
总结来看,用TypeScript封装IWAC表单验证类型,本质上是把一份文字规范翻译成机器可执行的契约。可辨识联合保证错误状态携带完整信息,条件类型保证分组控件的语义强制,映射类型消除重复接口,品牌类型兜底边界值。这套组合拳下来,团队产出的表单在结构层面天然合规,开发者也能从反复的手动检查中解放出来,把精力留给真正需要人判断的体验设计上。
TypeScript表单验证IWAC无障碍指南类型定义修改时间:2026-09-12 16:50:45