前置知识: TypeScript

索引签名与动态属性

3 minIntermediate2026/6/14

索引签名、Record与动态属性访问

概述

索引签名(Index Signature)是 TypeScript 中定义动态属性的核心机制,允许对象拥有任意数量的属性,只要这些属性的键和值符合指定的型。Record 工具型是索引签名的常用替代方案,提供了更精确的键约束。动态属性访问通过 keyof 和泛型约束实现型安全。理解索引签名、Record 和动态属性访问的使用场景和限制,对于处理配置对象、字典映射和动态数据结构至关重要。

基础概念

索引签名:在接口类型别名中使用 [key: Type]: ValueType 语法定义动态属性。键型只能是 string、number 或 symbol。

Record 工具类型Record<K, V> 创建键型为 K、值型为 V 的对象型。比索引签名更精确,可以限制键为特定的联合类型

动态属性访问:使用 obj[key] 语法访问动态属性。TypeScript 通过 keyof 和泛型约束确保键的型安全。

索引签名与已知属性:索引签名与已知属性共存时,已知属性的型必须是索引签名值型的子型。

快速上手

索引签名

// 字符串键的索引签名
interface StringMap {
  [key: string]: string;
}
const dict: StringMap = { name: '张三', city: '北京' };

// 数字键的索引签名
interface NumberMap {
  [key: number]: string;
}
const list: NumberMap = { 0: '第一项', 1: '第二项' };

// 混合类型值
interface Config {
  [key: string]: string | number | boolean;
  name: string; // 已知属性,必须是索引签名值类型的子类型
  version: number;
}
const appConfig: Config = { name: '应用', version: 1, debug: true };

Record 工具

// Record 限制键为特定联合类型
type UserRoles = Record<'admin' | 'editor' | 'viewer', boolean>;
const roles: UserRoles = { admin: true, editor: false, viewer: true };

// Record 与索引签名的区别
// 索引签名:键可以是任意 string
interface LooseMap {
  [key: string]: string;
}
const loose: LooseMap = { any: 'value' }; // 允许任意键

// Record:键必须是指定的联合类型
type StrictMap = Record<'a' | 'b', string>;
const strict: StrictMap = { a: '1', b: '2' }; // 必须包含 a 和 b

动态属性访问

// 类型安全的属性访问
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: '张三', age: 30, email: 'test@example.com' };
const name = getProperty(user, 'name'); // string
const age = getProperty(user, 'age'); // number
// getProperty(user, 'phone');          // 错误:'phone' 不是 user 的键

// 动态设置属性
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}
setProperty(user, 'name', '李四'); // 正确
// setProperty(user, 'name', 123); // 错误:name 应为 string

详细用法

索引签名与已知属性共存

// 已知属性的类型必须兼容索引签名
interface ApiResponse {
  [key: string]: string | number | boolean | object;
  status: number; // number 是 string | number | boolean | object 的子类型
  message: string; // string 是 string | number | boolean | object 的子类型
  data: object; // object 是 string | number | boolean | object 的子类型
}

// 错误示例:已知属性类型不兼容索引签名
// interface BadExample {
//   [key: string]: string;
//   count: number; // 错误:number 不能赋值给 string
// }

// 解决方案:使用联合类型
interface FixedExample {
  [key: string]: string | number;
  count: number;
  label: string;
}

模板字面量键

// 使用模板字面量类型约束键
type CSSProperties = {
  [K in `--${string}`]?: string;
};

const styles: CSSProperties = {
  '--primary-color': '#2196F3',
  '--font-size': '16px',
};

// 事件处理器映射
type EventKey = `on${Capitalize<string>}`;
type EventMap = {
  [K in EventKey]?: (...args: any[]) => void;
};

const handlers: EventMap = {
  onClick: () => console.log('clicked'),
  onChange: () => console.log('changed'),
};

嵌套索引签名

// 嵌套字典结构
interface NestedDict {
  [key: string]: {
    [key: string]: number;
  };
}

const scores: NestedDict = {
  math: { midterm: 85, final: 90 },
  english: { midterm: 78, final: 82 },
};

// 安全访问嵌套属性
function getNestedValue(obj: NestedDict, key1: string, key2: string): number | undefined {
  return obj[key1]?.[key2];
}

条件索引签名

// 根据条件选择不同的索引签名类型
type Dictionary<T, V> = T extends string ? Record<T, V> : { [key: string]: V };

type StringKeyDict = Dictionary<'a' | 'b', number>;
// Record<'a' | 'b', number> -> { a: number; b: number }

type AnyKeyDict = Dictionary<string, number>;
// { [key: string]: number }

常见场景

配置对象

// 应用配置:允许任意键,但值类型有限制
interface AppConfig {
  [key: string]: string | number | boolean | object;
  appName: string;
  version: number;
}

const config: AppConfig = {
  appName: '我的应用',
  version: 1,
  debug: true,
  api: { baseURL: 'https://api.example.com', timeout: 5000 },
};

// 读取配置
function getConfig<T extends string | number | boolean>(key: string, defaultValue: T): T {
  const value = config[key];
  if (typeof value === typeof defaultValue) {
    return value as T;
  }
  return defaultValue;
}

缓存映射

// 类型安全的缓存
class TypedCache<T> {
  private cache: Record<string, T> = {};

  set(key: string, value: T): void {
    this.cache[key] = value;
  }

  get(key: string): T | undefined {
    return this.cache[key];
  }

  has(key: string): boolean {
    return key in this.cache;
  }

  delete(key: string): boolean {
    return delete this.cache[key];
  }

  keys(): string[] {
    return Object.keys(this.cache);
  }

  values(): T[] {
    return Object.values(this.cache);
  }
}

// 使用
const userCache = new TypedCache<User>();
userCache.set('user:1', { id: 1, name: '张三' });
const user = userCache.get('user:1'); // User | undefined

国际化字典

// 国际化翻译字典
type Locale = 'zh-CN' | 'en-US';
type TranslationDict = Record<string, string>;

const translations: Record<Locale, TranslationDict> = {
  'zh-CN': {
    'app.title': '我的应用',
    'user.greeting': '你好',
    'button.submit': '提交',
  },
  'en-US': {
    'app.title': 'My App',
    'user.greeting': 'Hello',
    'button.submit': 'Submit',
  },
};

function t(locale: Locale, key: string): string {
  return translations[locale][key] ?? key;
}

注意事项

  • 索引签名的类型限制:索引签名的键型只能是 string、number 或 symbol。不能使用其他型(如联合类型)作为键型。
  • 数字键与字符串键:JavaScript 中对象的键总是字符串。数字键会被自动转换为字符串。TypeScript 区分数字索引签名和字符串索引签名,但运行时行为一致。
  • 索引签名与 Object.keysObject.keys() 返回 string[],而不是 keyof T[]。遍历对象键时需要型断言或类型守卫
  • Record 的完整性检查:Record 要求所有指定的键都存在。如果某些键是可选的,应使用 Partial<Record<K, V>>
  • satisfies 替代索引签名:当需要验证对象结构但保留具体型时,使用 satisfies 比索引签名更合适。

进阶用法

动态代理对象

// 使用 Proxy 创建类型安全的动态对象
function createTypedProxy<T extends Record<string, any>>(handler: (key: string) => T[string]): T {
  return new Proxy({} as T, {
    get(_, key: string) {
      return handler(key);
    },
  });
}

// 创建环境变量代理
const env = createTypedProxy<string>((key) => process.env[key] ?? '');

env.VITE_API_URL; // string
env.VITE_DEBUG; // string

递归索引

// 递归嵌套对象
type DeepRecord<T> = {
  [key: string]: T | DeepRecord<T>;
};

// 使用
const deepConfig: DeepRecord<string> = {
  api: {
    baseURL: 'https://api.example.com',
    auth: {
      clientId: 'app-123',
      clientSecret: 'secret',
    },
  },
  features: {
    darkMode: 'true',
  },
};

// 安全访问
function getDeepValue(obj: DeepRecord<string>, path: string): string | undefined {
  const keys = path.split('.');
  let current: DeepRecord<string> | string = obj;
  for (const key of keys) {
    if (typeof current === 'string') return undefined;
    current = current[key];
    if (current === undefined) return undefined;
  }
  return typeof current === 'string' ? current : undefined;
}