Menu

TypeScript 字典:索引签名、Record 与 Map

TypeScript 没有字典或哈希表类;你可以用索引签名、Record<K, V> 或 Map<K, V> 为键值查找标注类型。本文逐一讲解这三种方式,如何判断键是否存在、如何添加、删除和遍历,以及为什么 noUncheckedIndexedAccess 对字典很重要。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

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) !== undefinedMap与对象相同的 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.

该用哪一种

需求使用
字符串键,需要读写 JSONRecord<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 并不包含这个选项。

Coddy 编程语言插图

和 Coddy 一起学编程

开始