关于 index-signature-alternatives
这项 TypeScript 技能指导开发者用更精确的方式建模对象:已知属性使用 interface 或 Record,真正动态的键使用 Map。
主要功能
- 01已知属性使用 interface
- 02固定键集合使用 Record
- 03动态键使用 Map
- 04建模可选字段与有效组合
- 05识别适用索引签名的场景
适用场景
- 01设计 API 或配置类型
- 02表示 CSV 等动态数据
- 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 | undefined | T(不安全) |
| 原型问题 | 无 | 有 |
| 迭代顺序 | 有保证 | 无保证 |
| 任意键类型 | 是 | 仅限字符串/数字/符号 |
| 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