← 返回题目列表

TypeScript 索引签名和 Record 有什么区别?怎么给动态对象建模?

高频 中等 第 16 / 32 题 更新于 2026/07/29
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>;

它表示必须有 adminuser 两个 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,
};

这里 60300 是具体秒数,说明每个路径都映射到 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>可以约束只允许指定状态
第三方返回的松散字典索引签名加 unknownkey/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;最后补充索引签名的显式属性限制和 unknownany 更安全,这样能覆盖面试官常追的边界。