被 TypeScript 索引签名掩盖的 undefined

更新说明: TypeScript 4.1 引入了 noUncheckedIndexedAccess 编译选项,可以在一定程度上缓解本文讨论的问题。启用此选项后,Index Signature 访问会自动包含 | undefined 类型。

TypeScript 中的 Index Signature 使用频率很高。它可以用来表示通过方括号[]访问属性的数据结构,例如 TypeScript 内建类型Array<T>中就包含如下实现:

1
2
3
4
5
interface Array<T> {
...

[n: number]: T
}

同时,在 JavaScript 中相比 Map 类的实例,开发者更倾向于直接使用 object 来实现映射数据结构。虽然 Map 在某些方面更安全,但它存在序列化问题——JSON.stringify() 无法直接序列化 Map 对象,这在前后端数据交互中带来不便。TypeScript 将对象映射称为 Record,其类型定义为type Record<K extends keyof any, T> = { [P in K]: T }

然而,如果期望通过 TypeScript 实现完全的类型安全,需要谨慎使用 Index Signature。主要问题在于使用 Index Signature 获取的值类型并不准确,例如

1
2
3
4
5
6
7
const map: { [key: string]: number } = {
foo: 1,
bar: 2,
};

const value = map["baz"];
// value: number

这里使用不存在的 key 获取到的结果是undefined,但 TypeScript 推导出的值类型并不包含这种可能性。在 web 应用中经常使用 array 和 map 来批量处理数据或映射数据结构,这种隐藏的undefined可能导致运行时错误。

早在 2017 年初,社区就在 microsoft/TypeScript 提出了相关 issue。有人提出直接修改 Index Signature 的值定义,加上| undefined。但这个方案存在问题:在定义 map 时,我们只希望在获取值时类型能够表示值可能不存在,而在值类型定义后加上| undefined会改变语义,也无法阻止往集合中添加undefined值。更重要的是,这会影响迭代器的返回值类型——例如 Object.values() 的返回类型也会包含 undefined,这显然不符合预期。

这个问题至今未得到解决,根本原因在于 TypeScript 依然需要保持对 JavaScript 代码的完全兼容。JavaScript 的一些设计限制势必会影响 TypeScript 构建更安全的类型系统。这不仅仅体现在 Index Signatures 上,而是一个更广泛的架构约束。

作为静态类型的 superset,TypeScript 的语法设计不能与 JavaScript 产生冲突,这意味着它无法限制 JavaScript 的某些功能,也无法在运行时做任何额外的事情。而彻底解决 Index Signatures 的类型安全问题,恰好需要突破这些根本性的限制。

我时常想,如果脱离了 JavaScript 的约束,TypeScript 或许能成为一门更优雅的语言,但也会失去其最大的优势——渐进式采用和庞大的生态系统。现在的设计更多是现实考量下的权衡取舍。

当然,面对这样的现实,不同的开发者有不同的选择:有人选择妥协,有人通过各种变通方案维护内心的代码洁癖,也有人在心里默默期盼——愿世界没有 JS。