Record<K, V> 是一个内置工具类型,表示键的类型为 K、所有值的类型都为 V 的对象。用 string 作键时,它描述的是一个字典;用字面量联合类型作键时,它描述的是一个必须恰好拥有这些键的对象。
Record 只存在于类型系统中。在运行时,这两个对象都是普通的 JavaScript 对象,所以对象字面量、展开运算符、JSON.stringify 以及其他任何接受对象的地方都能用它们。
语法与定义
Record<Keys, Value>
Keys 必须是可以作为对象键的类型:string、number、symbol、字符串或数字字面量的联合类型,或者模板字面量类型。Value 可以是任意类型。TypeScript 标准库中的完整定义只有一行,是一个映射类型:
type Record<K extends keyof any, T> = {
[P in K]: T;
};
keyof any 就是 string | number | symbol,即所有可能的键类型。[P in K]: T 为 K 的每个成员创建一个类型为 T 的属性。这就解释了下面的两种行为:像 string 这样宽泛的 K 会生成索引签名(任意键),而联合类型的 K 会为每个成员生成一个必需属性。
联合类型作键:每个键都必须存在
当键是字面量的联合类型时,Record 必须列出其中的每一个键,而且不能有别的键。这样编译器就变成了一张检查清单:
给 Status 加上 "cancelled",这两个对象就会编译失败,直到你为新状态补上标签和颜色。漏掉一个键,或者加上一个不在联合类型中的键,都是编译错误:
index.ts(4,7): error TS2741: Property 'error' is missing in type '{ idle: string; loading: string; success: string; }' but required in type 'Record<Status, string>'.
index.ts(11,54): error TS2353: Object literal may only specify known properties, and 'paused' does not exist in type 'Record<Status, string>'.
用字符串枚举作键类型也是一样:Record<Color, string> 要求枚举的每个成员都有一项。
Record<string, T> 与缺失的键
用 string 作键时,任何键都是允许的,而且 TypeScript 会把每次读取的类型都当作 T,哪怕这个键根本不存在。在运行时,缺失的键会得到 undefined:
这是 Record 最常见的 bug。有三种处理方式:读取之前先用 in 或 Object.hasOwn 检查;把值声明为 V | undefined;或者开启编译选项 noUncheckedIndexedAccess,它会给项目中每一次索引签名读取都加上 | undefined。用联合类型作键的 Record 没有这个问题,因为每个键都保证存在。
Partial<Record<K, V>>:只需要部分键
想用联合类型作键、又不要求所有键都存在,就把 Record 包在 Partial 里。这样读取会返回 V | undefined,类型如实反映了情况:
拼错的键(比如 jp)仍然会报错,这正是它比 Record<string, string> 强的地方。
遍历 Record
Object.keys、Object.values 和 Object.entries 都能用。要注意的是键的类型:Object.keys 返回 string[],Object.entries 返回 [string, V][],永远不会是你的键联合类型:
TypeScript 故意把键保留为 string:对象在运行时拥有的属性可能比它的类型列出的更多,所以一般来说承诺返回 Plan[] 是不安全的。对于像 seats 这样由字面量创建的对象,这个断言是安全的。
用数据构建 Record
Record 通常是对数组做分组或建立索引的结果。从一个带 Record 类型的空对象开始,再往里填数据:
Book["genre"] 复用了接口中的联合类型作为键类型,所以给 Book 新增一个类别后,byGenre 就会要求补上对应的一项。
Record<string, unknown> 与接口
Record<string, unknown> 常用来表示“某个键为字符串的对象”。它接受对象字面量和用 type 别名声明类型的值,但会拒绝接口:
index.ts(11,11): error TS2345: Argument of type 'User' is not assignable to parameter of type 'Record<string, unknown>'.
Index signature for type 'string' is missing in type 'User'.
接口可以通过声明合并来扩展,所以 TypeScript 不会假定它符合索引签名;类型别名无法重新打开,所以 type User = { name: string } 能通过检查。常见的修复方法有:改为接受 object(照样可以对它调用 Object.keys)、把函数写成泛型(<T extends object>(obj: T)),或者用 type 声明 User(interface 与 type 一文介绍了两者的其他区别)。
Record、索引签名与 Map 对比
Record<K, V> | { [key: string]: V } | Map<K, V> | |
|---|---|---|---|
| 运行时是否存在 | 否,是普通对象 | 否,是普通对象 | 是,是一个类 |
| 固定的键集合 | 是,K 为联合类型时 | 否 | 否 |
| 键的类型 | string、number、symbol、字面量联合类型、模板模式 | string、number、symbol、模板模式 | 任意类型,包括对象 |
| 读取缺失键的类型 | V(string 作键时) | V | get 返回 V | undefined |
| 与具名属性混用 | 通过交叉类型 & | 可以,写在同一个类型里 | 不可以 |
| JSON 与展开运算符 | 支持 | 支持 | 不支持,需先转换 |
| 大小 | Object.keys(r).length | Object.keys(o).length | m.size |
| 频繁增删 | 可以 | 可以 | 专为此设计 |
只要键的集合是已知的,就选用联合类型作键的 Record:它是唯一能检查每个键都存在的方案。对于不固定的字符串键,Record<string, V> 和索引签名可以互换,很多代码库为了可读性更喜欢用 Record。当键会在运行时增删、键不是字符串,或者你需要直接拿到大小和插入顺序时,就用 Map。
常见问题
TypeScript 中的 Record 是什么?
Record<K, V> 是一个内置工具类型,表示键的类型为 K、所有值的类型都为 V 的对象。Record<string, number> 是键为任意字符串、值为数字的对象;Record<"en" | "de", string> 是恰好拥有 en 和 de 两个键、值都为字符串的对象。
TypeScript 中 Record 和 Map 有什么区别?
Record 是普通 JavaScript 对象的类型,所以它能配合对象字面量、JSON 和展开运算符使用,并且在编译时消失。Map 是运行时的类,提供 get、set、has 和 size,对所有键都保留插入顺序,接受任意类型的键(包括对象),而且 get 返回 V | undefined。固定的或 JSON 形状的数据用 Record,运行时会增删键的场景用 Map。
Record<string, T> 和 { [key: string]: T } 有什么区别?
就值而言它们是同一个类型:Record<string, T> 展开后就是带字符串索引签名的对象类型。有两个小区别:索引签名可以带一个名字,并且能和其他属性写在同一个类型里;keyof Record<string, T> 是 string,而 keyof { [key: string]: T } 是 string | number。
如何在 TypeScript 中遍历 Record?
用 Object.entries(record) 获取键值对,用 Object.keys 获取键,用 Object.values 获取值。返回的键是 string 而不是 K,因为对象在运行时可能带有额外的键。当 Record 的键是已知的联合类型时,可以做类型断言:(Object.keys(r) as Array<keyof typeof r>)。
如何让 Record 只有部分键是必需的?
使用联合类型作键时,Record<K, V> 要求每个键都存在。用 Partial 包一层就能让所有键都变成可选:Partial<Record<Lang, string>>。如果要混合,就用交叉类型:Record<"en", string> & Partial<Record<"de" | "fr", string>> 要求必须有 en,其余的键可有可无。