坦桑尼亚的网络无障碍指南(与WCAG理念相通)明确要求:页面不能把颜色作为传达信息的唯一手段,文字与背景的对比度也必须达到可感知的最低标准。这类要求如果只靠人工评审或事后测试,往往在迭代中悄悄被破坏。TypeScript的类型系统恰好提供了一条编译期防线——把无障碍规则表达成类型约束,让不合规的配色写法直接过不了编译。本文以IWAC项目为例,讲讲具体的封装思路。

为什么用类型系统而不是运行时校验
常见的做法是在组件库内部跑运行时检查,比如在开发模式下用console.warn提示对比度不足。这种方案的缺点很明显:警告可以被忽略,错误只在渲染时才暴露,而且每个组件都要重复写一遍检查逻辑。CI阶段的axe或eslint插件虽然更严格,但报错位置距离写代码的时间点已经隔了一次提交。
类型系统则不同。当把「这个按钮的文字颜色必须与背景色形成足够对比」编码成泛型约束后,任何不满足约束的调用都会在编辑器里直接飘红,错误定位到具体那一行调用代码。开发者不需要记住指南的具体条款,类型本身就成了文档。此外,类型信息是可以组合的——基础的颜色令牌定义一次,派生规则可以覆盖按钮、标签、状态提示等多种场景。
代价当然是有的:类型体操的复杂度会上升,团队需要一定的TypeScript基础。所以封装时要控制好对外暴露的API复杂度,把推导细节藏在内部工具类型里,只让使用者面对简单直观的参数。
定义带语义的颜色令牌与基础类型
第一步是建立颜色令牌(token)模型。坦桑尼亚指南强调的可感知性,落到实现上就是每个颜色都要携带亮度信息,这样才能在类型层面计算对比度。我们先定义一个用RGB三元组表示的颜色类型:
// 将数字字面量约束到合法的RGB区间
type RGBChannel = number & { __brand: 'RGBChannel' };
interface ColorToken<R extends number, G extends number, B extends number> {
r: R;
g: G;
b: B;
__kind: 'color';
}
// 工厂函数:运行时校验 + 品牌类型
function defineColor(r: number, g: number, b: number) {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
return {
r: clamp(r) as RGBChannel,
g: clamp(g) as RGBChannel,
b: clamp(b) as RGBChannel,
__kind: 'color' as const,
};
}
const primaryText = defineColor(26, 26, 26);
const surfaceLight = defineColor(250, 250, 250);这里用了品牌类型(branded type)的思路,防止任意数字直接充当颜色值。真正的核心是接下来在类型层面计算相对亮度。TypeScript无法对小数做精确的算术,所以常见做法是把亮度计算简化为分档:预先为每个通道值建立查表,把连续的RGB空间离散化。
// 简化:亮度粗略估算,把每通道贡献量化到0-9 type BrightnessLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; // 利用模板字面量类型把数字拆位相加的技巧可以做得更精细 // 这里以查表映射为例演示思路 type ChannelBrightness<C extends number> = C extends 0 | 1 | 2 | 3 ? 0 : C extends 4 | 5 | 6 | 7 ? 1 : // ...中间档位省略 9; type TokenBrightness<T extends ReturnType<typeof defineColor>> = BrightnessLevel;
篇幅所限,完整的查表类型写出来很长,思路是:为0到255的每个数字映射一个0到9的档位,再用交叉类型把三个通道的档位组合成整体亮度区间。虽然看起来笨重,但它只在库内部维护一次,使用者完全无感。
封装对比度约束与条件类型校验
有了亮度档位,就可以写对比度规则了。指南要求正文文字对比度至少4.5:1,大号文字3:1。在类型层面我们用一个条件类型来表达「合格或给出错误提示」:
interface AA_Contrast {}
interface AAA_Contrast {}
type ContrastOK<F extends number, B extends number> =
[F, B] extends [0, 9] | [1, 9] | [0, 8] ? AA_Contrast :
[F, B] extends [9, 0] | [8, 0] ? AA_Contrast :
never;
// 泛型按钮的约束
function createAccessibleButton<
F extends ColorToken<number, number, number>,
B extends ColorToken<number, number, number>
>(fg: F, bg: B): ContrastOK<TokenBrightness<F>, TokenBrightness<B>> {
return {} as AA_Contrast;
}
// 正确:深色文字配浅色背景,返回正常类型
createAccessibleButton(primaryText, surfaceLight);
// 错误:两个亮度档位太接近,返回never,编译报错
createAccessibleButton(surfaceLight, surfaceLight);当对比度不达标时,函数返回类型退化为never,TypeScript会报出「类型never不可赋值」之类的错误。为了提示更友好,可以用重载错误消息的技巧,把返回类型做成一个带说明文字的对象类型,让报错信息直接引用指南条款编号。
另外一条重要规则是「不能仅靠颜色传达信息」。这可以建模为:凡接受表示状态的色值的组件,必须同时接受一个非视觉标识参数。用交叉类型强制这个约束:
interface RequiresNonVisualCue {
/** 状态不能只靠颜色表达,必须提供图标或文字 */
nonVisualCue: 'icon' | 'text' | 'pattern';
}
type StatusColor<T> = T & RequiresNonVisualCue;
// 状态标签的props:颜色与提示共存
interface BadgeProps<T extends object> {
color: StatusColor<T>;
label: string;
}这样任何只传颜色、不传替代提示的用法都会在编译期失败,正好对应指南中「错误状态不能只用红色表示」这类典型条款。
落地建议与局限
整套方案落地时建议分层推进:先在design token层面统一颜色定义,禁止散落的魔法色值;再给高频组件(按钮、标签、表单提示)加约束;最后才考虑覆盖低频场景。类型推导失败的报错要写得足够清楚,否则团队成员的第一反应会是加as any绕过去,那就前功尽弃了。可以在CI里加一条规则,禁止特定文件中出现针对这些类型的断言。
也要承认局限:类型层面的亮度计算是离散近似,无法做到WCAG公式的精确结果,边界值可能误判。因此它适合作为第一道防线,而不是替代axe等自动化测试。两者结合,编译期拦截明显问题,测试期兜底精确校验,才能既守住坦桑尼亚无障碍指南的要求,又不至于让类型系统复杂到失控。
TypeScript无障碍开发IWAC修改时间:2026-09-12 15:06:47