mcpField
I
index-signature-alternatives

index-signature-alternatives

github.com/marius-townhouse开发643 次安装38 周前更新
代码仓库

关于 index-signature-alternatives

这项 TypeScript 技能指导开发者用更精确的方式建模对象:已知属性使用 interface 或 Record,真正动态的键使用 Map。

主要功能

  1. 01已知属性使用 interface
  2. 02固定键集合使用 Record
  3. 03动态键使用 Map
  4. 04建模可选字段与有效组合
  5. 05识别适用索引签名的场景

适用场景

  1. 01设计 API 或配置类型
  2. 02表示 CSV 等动态数据
  3. 03检查可接受任意键的类型

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/marius-townhouse/effective-typescript-skills --skill index-signature-alternatives

安装后在 Claude Code 中输入/index-signature-alternatives确认已加载。

详细介绍

优先选择比索引签名更精确的替代方案

概述

索引签名不够精确。应改用接口、Record 或 Map。

索引签名({[key: string]: T})允许任意字符串键,不要求特定键,也无法为不同的键指定不同类型。几乎总有更好的替代方案。

何时使用此技能

  • 定义具有已知属性名称的类型
  • 为 API 或配置文件中的数据建模
  • 处理 CSV 或动态数据
  • 在对象类型和 Map 之间进行选择

铁律

If you know the property names, DON'T use an index signature.
Use an interface, Record, or mapped type instead.

请记住:

  • 索引签名允许任意键(包括拼写错误)
  • 索引签名不要求任何特定键
  • 索引签名无法为不同的键指定不同类型
  • 语言服务(自动补全)对索引签名的支持不佳

检测:索引签名的问题

// Index signature: too permissive
type Rocket = { [property: string]: string };

const rocket: Rocket = {
  name: 'Falcon 9',
  variant: 'Block 5',
  thrust: '7,607 kN',
};

// Problems:
rocket.Name;    // Typo compiles (should be 'name')
const r: Rocket = {};  // Empty object is valid
rocket.thrust;  // Can't be a number, even though it should be

更好的替代方案

1. 接口(适用于已知属性)
interface Rocket {
  name: string;
  variant: string;
  thrust_kN: number;  // Can have different types
}

const falconHeavy: Rocket = {
  name: 'Falcon Heavy',
  variant: 'v1',
  thrust_kN: 15200,
};

// Benefits:
// - Typos caught: rocket.Name is an error
// - Required fields enforced
// - Each field has its own type
// - Autocomplete works
2. Record(适用于已知键的联合类型)
// Limited set of keys, same value type
type Vec3D = Record<'x' | 'y' | 'z', number>;
// Same as: { x: number; y: number; z: number }

type CSSColors = Record<'primary' | 'secondary' | 'accent', string>;
3. 可选属性(适用于部分属性集合)
// When you know possible keys but not all will be present
interface Row {
  a: number;
  b?: number;
  c?: number;
  d?: number;
}
4. 联合类型(适用于精确组合)
// When specific combinations are valid
type Row =
  | { a: number }
  | { a: number; b: number }
  | { a: number; b: number; c: number };
5. Map(适用于真正动态的键)
// When keys are genuinely unknown at compile time
function parseCSV(input: string): Map<string, string>[] {
  const lines = input.split('\n');
  const [headerLine, ...rows] = lines;
  const headers = headerLine.split(',');
  
  return rows.map(rowStr => {
    const row = new Map<string, string>();
    rowStr.split(',').forEach((cell, i) => {
      row.set(headers[i], cell);
    });
    return row;
  });
}

const rockets = parseCSV(csvData);
const thrust = rockets[0].get('thrust_kN');
//    ^? const thrust: string | undefined  (safer!)

适合使用索引签名的情况

允许附加属性
interface ButtonProps {
  title: string;
  onClick: () => void;
  [otherProps: string]: unknown;  // Allow any extra props
}

renderButton({
  title: 'Click me',
  onClick: () => {},
  theme: 'dark',  // OK now
  'data-testid': 'submit-btn',  // OK
});
模板字面量约束
// Only allow keys starting with 'data-'
interface DataProps {
  [key: `data-${string}`]: string;
}

const props: DataProps = {
  'data-testid': 'my-button',
  'data-value': '42',
  // 'theme': 'dark',  // Error! Key must start with 'data-'
};

Map 与带索引签名的对象对比

特性Map索引签名
.get() 返回值T | undefinedT(不安全)
原型问题无有
迭代顺序有保证无保证
任意键类型是仅限字符串/数字/符号
TypeScript 支持良好自动补全更好
// Map is safer for dynamic data
const scores = new Map<string, number>();
const score = scores.get('alice');
//    ^? const score: number | undefined

// Index signature pretends value always exists
const scoreObj: { [name: string]: number } = {};
const score2 = scoreObj['alice'];
//    ^? const score2: number  (but it's actually undefined!)

将动态数据转换为类型

// Parse dynamic data, validate, return typed object
function parseRocket(map: Map<string, string>): Rocket {
  const name = map.get('name');
  const variant = map.get('variant');
  const thrust_kN = Number(map.get('thrust_kN'));
  
  if (!name || !variant || isNaN(thrust_kN)) {
    throw new Error(`Invalid rocket: ${JSON.stringify([...map])}`);
  }
  
  return { name, variant, thrust_kN };
}

// Now you have type safety
const rockets = parseCSV(csvData).map(parseRocket);
//    ^? const rockets: Rocket[]

抗压应对方案

1. “我不知道所有键”

压力:“这些键来自用户输入/API”

**回应:**真正动态的数据使用 Map,然后验证并转换为类型化接口。

**行动:**输入时使用 Map<string, string>,然后解析为接口。

2. “索引签名更简单”

压力:“直接用 {[k: string]: any},别管了”

**回应:**这样会失去所有类型安全性和自动补全。

**行动:**定义实际结构,即使需要写更多代码。

危险信号——停止并重新考虑

  • 已知属性名称的索引签名
  • 在任何地方使用 [key: string]: any
  • 输入属性名称时没有自动补全
  • TypeScript 无法发现属性名称中的拼写错误

常见借口(都不成立)

借口实际情况
“键是动态的”通常它们在编译时其实是已知的
“属性太多,列不完”Record 或映射类型可以处理
“这只是配置”配置有模式,应当定义它

快速参考

// DON'T: Index signature for known keys
type Bad = { [key: string]: string };

// DO: Interface for known keys
interface Good { name: string; value: string; }

// DO: Record for union of keys
type Colors = Record<'red' | 'green' | 'blue', number>;

// DO: Map for truly dynamic keys
const data = new Map<string, unknown>();

// DO: Index signature only for extra properties
interface Props {
  required: string;
  [extra: string]: unknown;
}

总结

索引签名牺牲了精确性,换来的灵活性通常并非你所需。

如果知道属性名称,就使用接口。如果键的集合是已知的,就使用 Record。如果键确实是动态的,就使用 Map。仅在明确希望允许附加属性时才使用索引签名。

参考资料

基于 Dan Vanderkam 的《Effective TypeScript》第 16 条:优先选择比索引签名更精确的替代方案。

评论

支持 Markdown