TypeScript 索引签名和 Record 有什么区别?怎么给动态对象建模?
简化版
索引签名适合描述“未知 key、统一 value 类型”的对象,Record<K, V> 是用映射类型批量生成 key 到 value 的对象类型。面试里要强调:动态对象不能随便写成 any,key 的范围越明确,类型越安全。
详细版
索引签名常写成:
type Dict = {
[key: string]: number;
};
它表示任意字符串 key 都对应 number。
Record 常写成:
type RoleMap = Record<'admin' | 'user', boolean>;
它表示必须有 admin 和 user 两个 key,且 value 都是 boolean。区别是:索引签名偏开放,Record 可以更精确地约束 key 集合。实际项目里,配置表、权限表、状态码映射、缓存字典都很常见。
完整版教学
一、动态对象为什么不能直接写 any
前端经常遇到“key 不是固定字段名”的对象,比如权限表、路由表、接口错误码文案、缓存字典。
如果为了省事写成 any,TypeScript 就无法检查 value 类型,也无法提示 key 的使用方式。
动态对象真正需要表达的是:key 可以变化,但 value 仍然有规则。
const messages: any = {
401: '请先登录',
500: '服务异常',
};
messages[401].toFixed(2); // 编译期不报错,运行时才炸
把这个例子改成 Record<number, string> 后,toFixed 会在编译期直接报错。
这就是动态对象建模的核心:不是把对象放开,而是把“动态的边界”说清楚。
二、索引签名描述开放字典
索引签名的语法是 [key: KeyType]: ValueType。
它适合 key 无法提前枚举,但所有 value 都遵守同一种结构的场景。
例如一个缓存命中字典,key 是接口路径,value 是缓存时间。
type CacheTTL = {
[path: string]: number;
};
const ttl: CacheTTL = {
'/api/user': 60,
'/api/order': 300,
};
这里 60 和 300 是具体秒数,说明每个路径都映射到 number。
如果把 '/api/user': '60s' 写进去,TypeScript 会提示不满足 number。
但它不会要求必须存在某几个 key,因为索引签名表达的是开放集合。
三、Record 更适合已知 key 集合
Record<K, V> 本质上是一个映射类型。
当 key 范围可以枚举时,Record 比开放索引签名更严格。
例如角色权限表必须覆盖 3 个角色,漏一个就应该在开发期报错。
type Role = 'admin' | 'editor' | 'guest';
type PermissionMap = Record<Role, string[]>;
const permissions: PermissionMap = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
guest: ['read'],
};
如果漏掉 guest,编译器会报错。
这类“必须覆盖所有枚举值”的配置表,特别适合 Record。
| 场景 | 更适合的写法 | 原因 |
|---|---|---|
| 任意接口路径到 TTL | 索引签名 | key 集合未知 |
| 固定角色到权限 | Record<Role, Permission> | key 集合已知且要完整 |
| 状态码到文案 | Record<Status, string> | 可以约束只允许指定状态 |
| 第三方返回的松散字典 | 索引签名加 unknown | key/value 都需要再校验 |
四、字符串索引签名会影响显式属性
索引签名有一个容易踩的点:一旦声明了字符串索引签名,所有显式属性也必须兼容 value 类型。
因为 obj.name 本质上也可以被看成 obj['name']。
所以显式字段不能和索引签名冲突。
type Bad = {
name: string;
[key: string]: number;
};
// name 不兼容 number,会报错
正确做法通常是把动态部分单独包一层。
type Product = {
name: string;
stockByWarehouse: Record<string, number>;
};
这样 name 还是 string,仓库库存字典仍然是 number。
这个拆层技巧在真实业务里很重要,否则为了迁就索引签名会把类型越写越宽。
五、数字 key 和字符串 key 的运行时边界
JavaScript 对象的属性 key 在运行时会被转成字符串,obj[1] 和 obj['1'] 很多时候访问的是同一个位置。
TypeScript 支持 number 索引签名,但要理解它和 JS 运行时模型的关系。
例如状态码映射写成 Record<number, string> 可以用,但它不等于运行时真的保留 number key。
const text: Record<number, string> = {
200: 'OK',
404: 'Not Found',
};
console.log(text[404]); // Not Found
console.log(text['404']); // 也能取到
流程可以这样理解:
源码里写 404
-> 对象属性名运行时转成 '404'
-> TypeScript 在类型层允许 number 索引访问
-> 运行时仍然按 JS 对象属性规则读取
如果 key 真的是复杂对象,应该使用 Map,而不是普通对象。
六、动态数据更推荐 unknown 而不是 any
接口返回的动态对象不一定可信。
如果 value 结构未知,写成 Record<string, unknown> 比 Record<string, any> 更安全。
unknown 会强迫你在使用前做类型收窄,而 any 会绕过检查。
function readValue(dict: Record<string, unknown>, key: string) {
const value = dict[key];
if (typeof value === 'string') {
return value.trim();
}
return '';
}
记忆钩子:索引签名回答“这个字典允许什么 key/value”,
Record回答“这一组 key 都要映射到什么 value”。
一个具体判断法:如果 key 有 3 个固定候选,优先 Record<'a' | 'b' | 'c', V>;如果 key 可能有 300 个接口路径且无法提前列完,用索引签名或 Record<string, V>。
七、常见误区与追问
- 误区:动态对象只能写成 any。 动态不代表失控,至少可以用索引签名、
Record<string, unknown>或更精确的 key 联合来约束。 - 误区:索引签名和 Record 完全一样。 它们都能表达字典,但索引签名偏开放,
Record更适合已知 key 集合和映射类型组合。 - 误区:
Record<number, V>的 key 运行时一定是 number。 普通对象属性运行时仍按 JavaScript 规则处理,很多 number key 会转成字符串属性名。 - 追问:为什么字符串索引签名会限制显式属性? 因为显式属性也可以通过字符串下标访问,所以它的类型必须兼容索引签名的 value 类型。
- 追问:什么时候用 Map 而不是 Record? 当 key 不是字符串、数字或 symbol,或者需要稳定的插入顺序、频繁增删、对象 key 时,
Map更合适。 - 追问:接口返回的字典 value 不确定怎么办? 用
unknown接住,再通过typeof、自定义类型守卫或运行时校验库收窄。
八、加强记忆
索引签名记成“开放字典”,Record 记成“批量映射”。回答时先说动态对象的目标不是放弃类型,而是约束 key 和 value;再用权限表说明 Record<'admin' | 'guest', string[]> 能检查缺 key;最后补充索引签名的显式属性限制和 unknown 比 any 更安全,这样能覆盖面试官常追的边界。