TypeScript 没有单独的字典或哈希表类。字典要么是用索引签名 { [key: string]: number } 标注类型的普通对象(同一个类型也可以写作 Record<string, number>),要么是 Map<string, number>。三者都按键存储值;区别在于键的类型、缺失的键如何标注类型,以及如何序列化。
对于字符串键和 JSON 形式的数据,通常选用 Record<string, T> 类型的对象。对于非字符串键,或者条目经常增删的情况,使用 Map。
索引签名
索引签名 [key: KeyType]: ValueType 的意思是“这种类型的任何键都对应这种类型的值”。键名(key、name、userId)只起说明作用。键的类型可以是 string、number、symbol、模板字面量模式,或者它们的联合类型。
JavaScript 会把数字键转换成字符串,所以 { [id: number]: string } 对象在运行时的键仍然是字符串:Object.keys({ 1: "one" }) 的结果是 [ '1' ]。数字索引签名只是限制了你在 TypeScript 中能用什么来索引它。
Record<K, V>
Record<string, V> 是 { [key: string]: V } 的简写。如果用字面量键的联合类型代替 string,它就成了一个必须包含所有键的固定字典:
在 urls 中漏掉 staging 会报编译错误 TS2741(Property 'staging' is missing...),这让带联合类型键的 Record 成为一张经过检查的查找表。Partial 则会让每个值变成 number | undefined。
把 Map 当作哈希表
Map 接受任何类型的键,保留插入顺序,有 size 属性,而且如实地为缺失的键标注类型:get 返回 V | undefined。
判断键是否存在
有好几种检查方式,它们的含义并不完全相同:
| 检查方式 | 适用于 | 注意事项 |
|---|---|---|
Object.hasOwn(obj, key) | 对象 | ES2022 才有;在更早的目标上使用 Object.prototype.hasOwnProperty.call(obj, key) |
key in obj | 对象 | 对 toString、constructor 这类继承来的键也返回 true |
obj[key] !== undefined | 对象 | 无法区分缺失的键和值为 undefined 的键 |
if (obj[key]) | 对象 | 值为 0、"" 和 false 时也会判为 false |
map.has(key) | Map | 不会收窄之后 map.get(key) 的类型 |
map.get(key) !== undefined | Map | 与对象相同的 undefined 问题 |
继承键的问题正是为什么用户提供的 "constructor" 或 "__proto__" 这类键会让普通对象当字典变得有风险。Map 没有这样的键。
缺失键的类型问题
对于索引签名或 Record<string, T>,读取任何键得到的类型都是 T,即使这个键并不存在。编译器会允许你在一个运行时为 undefined 的值上调用方法:
编译选项 noUncheckedIndexedAccess 可以解决这个问题:开启后,colors["grass"] 的类型是 string | undefined,在你做检查之前,调用 toUpperCase 会报编译错误。它不属于 strict,所以必须在 tsconfig.json 中单独开启;与它并列的其他开关请参阅严格模式。Map 没有这个漏洞,因为 get 的返回类型总是包含 undefined。
添加、删除和遍历
delete 可以用于索引签名属性。如果用在对象类型中必需的具名属性上,会报编译错误 TS2790:The operand of a 'delete' operator must be optional.
该用哪一种
| 需求 | 使用 |
|---|---|
| 字符串键,需要读写 JSON | Record<string, T> |
| 一组固定的已知键,且都必需 | Record<"a" | "b", T> |
| 具名属性加上任意额外的键 | 带索引签名的对象类型 |
| 键是对象、要保持为数字的数字,或任何非字符串 | Map<K, V> |
| 频繁增删,或者经常读取大小 | Map<K, V> |
| 键来自用户输入 | Map<K, V>(没有继承来的键) |
常见问题
如何在 TypeScript 中创建字典?
用索引签名为普通对象标注类型:const ages: { [name: string]: number } = {},或者写成等价的 Record<string, number>。然后用 ages["ada"] = 36 添加条目。如果键不是字符串,或者集合需要频繁地添加和删除,就使用 new Map<string, number>()。
TypeScript 有 HashMap 吗?
没有叫这个名字的类型。JavaScript 内置的 Map 就是哈希表:Map<K, V> 存储键值对,按键查找很快,保留插入顺序,并且接受任何类型的键。对于字符串键,另一种常见选择是类型为 Record<string, V> 的普通对象。
如何判断 TypeScript 字典中是否存在某个键?
对于对象字典,使用 Object.hasOwn(dict, key) 或 key in dict(后者还会看到 toString 这类继承来的属性),或者读取值并与 undefined 比较。对于 Map,使用 map.has(key),或者直接检查 map.get(key) 的结果,因为 has 不会收窄之后 get 的类型。
索引签名和 Record 有什么区别?
{ [key: string]: T } 和 Record<string, T> 描述的是同一个类型。Record 更简短,还可以接收由具体键组成的联合类型,如 Record<"a" | "b", T>,此时每个键都是必需的。索引签名可以在同一个对象类型中与具名属性组合使用,它的键还可以起一个说明用途的名字,比如 { [userId: string]: User }。
为什么读取字典中不存在的键不会报错?
默认情况下,对索引签名或 Record<string, T> 读取 dict[key] 得到的类型是 T,尽管运行时缺失的键对应的值是 undefined。在 tsconfig.json 中开启 noUncheckedIndexedAccess,类型就会变成 T | undefined,迫使你先做检查。strict 并不包含这个选项。