关于 tagged-unions
面向 TypeScript 开发者的编码技能,指导将属性间存在依赖的接口改为带标签的接口联合。它强调用类型表达有效状态,并借助类型收窄和穷尽检查发现错误。
主要功能
- 01将依赖属性拆分为接口联合
- 02使用字面量标签区分变体
- 03利用类型收窄访问变体字段
- 04通过穷尽检查覆盖所有状态
- 05为外部数据定义内部类型
适用场景
- 01建模不同状态对应的数据
- 02避免相关可选字段出现不一致
- 03为状态分支添加编译期检查
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill tagged-unions安装后在 Claude Code 中输入/tagged-unions确认已加载。
详细介绍
优先使用接口的联合类型,而不是带联合类型的接口
概述
当接口的属性采用联合类型时,通常使用接口的联合类型更好。
带标签的联合类型(可辨识联合类型)能明确属性之间的关系,并支持穷尽式类型检查。
何时使用此技巧
- 只有组合使用才有意义的接口属性
- 各种状态对应不同数据的状态类型
- 存在依赖关系的多个布尔标志
- 具有隐式关系的联合类型属性
- 针对字符串/枚举状态字段的 switch 语句
铁律
NEVER create interfaces where property combinations can be invalid.
没有例外:
- 不能以“这样更简单”为理由
- 不能以“我们会记录这种关系”为理由
- 不能以“我们会在运行时验证”为理由
检测:“带联合类型的接口”异味
如果属性之间存在依赖关系,就拆分为接口的联合类型。
// ❌ VIOLATION: Allows invalid combinations
interface Layer {
type: 'fill' | 'line' | 'point';
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
// These are technically valid but make no sense:
const badLayer: Layer = {
type: 'fill',
layout: new LineLayout(), // Wrong layout for fill!
paint: new PointPaint(), // Wrong paint for fill!
};
解决方案:带标签的联合类型
// ✅ CORRECT: Each variant is explicit
interface FillLayer {
type: 'fill';
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
type: 'line';
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
type: 'point';
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
// Now invalid combinations are impossible:
const badLayer: Layer = {
type: 'fill',
layout: new LineLayout(), // Error! Not assignable to FillLayout
paint: new PointPaint(),
};
妙处:自动缩窄类型
function drawLayer(layer: Layer) {
switch (layer.type) {
case 'fill':
// TypeScript knows: layer is FillLayer
console.log(layer.paint); // FillPaint
console.log(layer.layout); // FillLayout
break;
case 'line':
// TypeScript knows: layer is LineLayer
console.log(layer.paint); // LinePaint
break;
case 'point':
// TypeScript knows: layer is PointLayer
console.log(layer.paint); // PointPaint
break;
}
}
示例:请求状态
// ❌ BAD: Allows invalid states
interface RequestState {
status: 'pending' | 'loading' | 'success' | 'error';
data?: ResponseData;
error?: Error;
}
// What does this mean?
const weird: RequestState = {
status: 'error',
data: someData, // Has data but errored?
};
// ✅ GOOD: Tagged union
interface RequestPending { status: 'pending' }
interface RequestLoading { status: 'loading' }
interface RequestSuccess { status: 'success'; data: ResponseData }
interface RequestError { status: 'error'; error: Error }
type RequestState = RequestPending | RequestLoading | RequestSuccess | RequestError;
// Each state has exactly the data it needs
function handleRequest(state: RequestState) {
switch (state.status) {
case 'pending':
return <Spinner />;
case 'loading':
return <LoadingBar />;
case 'success':
return <DataView data={state.data} />; // data is guaranteed
case 'error':
return <ErrorView error={state.error} />; // error is guaranteed
}
}
可选属性:将它们分组
// ❌ BAD: Related optional fields
interface Person {
name: string;
placeOfBirth?: string; // These should be
dateOfBirth?: Date; // together or absent
}
// Valid but inconsistent:
const person: Person = {
name: 'Alice',
placeOfBirth: 'NYC', // Has place but no date?
};
// ✅ GOOD: Group related optional fields
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}
// Now they're always together:
function printBirth(person: Person) {
if (person.birth) {
// Both place AND date are guaranteed
console.log(`${person.birth.place} on ${person.birth.date}`);
}
}
标签必须是字面量类型
// ❌ BAD: Tag is too broad
interface Shape {
type: string; // Any string - can't narrow!
}
// ✅ GOOD: Tag is a literal union
interface Circle { type: 'circle'; radius: number }
interface Square { type: 'square'; side: number }
type Shape = Circle | Square;
// Now narrowing works:
function area(shape: Shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius ** 2; // TypeScript knows it's Circle
}
return shape.side ** 2; // TypeScript knows it's Square
}
穷尽式检查
带标签的联合类型支持穷尽式检查:
function assertNever(x: never): never {
throw new Error(`Unexpected: ${x}`);
}
function area(shape: Shape): number {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
default:
return assertNever(shape); // Error if we miss a case!
}
}
// Later, if we add Triangle:
type Shape = Circle | Square | Triangle;
// TypeScript errors in assertNever:
// Argument of type 'Triangle' is not assignable to parameter of type 'never'
无法更改类型时
如果数据来自你无法控制的 API:
// External API returns this shape
interface APIResponse {
type: 'user' | 'admin';
name: string;
permissions?: string[]; // Only for admin
}
// Create a better internal type:
interface User { type: 'user'; name: string }
interface Admin { type: 'admin'; name: string; permissions: string[] }
type Person = User | Admin;
// Transform at the boundary:
function transformResponse(response: APIResponse): Person {
if (response.type === 'admin') {
return {
type: 'admin',
name: response.name,
permissions: response.permissions ?? [],
};
}
return { type: 'user', name: response.name };
}
抵御压力的规范
1. “接口太多了”
压力:“一个接口比四个接口简单”
**回应:**无效状态很难调试。
**行动:**编写这些接口。它们也能充当文档。
2. “我们会记录依赖关系”
压力:“注释会说明字段何时适用”
**回应:**类型是更好的文档,而且会经过检查。
**行动:**让类型表达这些关系。
警示信号——停下来重新考虑
- 接口中有多个彼此相关的可选字段
- 带有可选数据字段的状态枚举
- 先检查类型再访问数据的 switch 语句
- 解释“如果 X,那么 Y 就会被设置”的注释
- 针对字段组合进行运行时验证
常见的自我开脱(都不成立)
| 借口 | 事实 |
|---|---|
| “这样更简单” | 无效状态并不好调试。 |
| “我们会验证” | 类型能在编译时捕获错误。 |
| “接口太多了” | 总比 Bug 太多好。 |
快速参考
| 模式 | 解决方案 |
|---|---|
| 状态 + 可选数据/错误 | 为每种状态定义带标签的联合类型 |
| 类型判别字段 + 联合字段 | 为每种类型定义带标签的联合类型 |
| 彼此相关的可选字段 | 使用可选的嵌套对象 |
| 存在依赖关系的布尔标志 | 为每种状态定义带标签的联合类型 |
总结
如果属性之间存在依赖关系,就在类型系统中表达这种关系。
使用带标签的联合类型(可辨识联合类型)明确表达关系。这样可以获得穷尽式检查、更好的类型缩窄,以及只能表示有效状态的类型。
参考资料
根据 Dan Vanderkam 的《Effective TypeScript》第 34 条:优先使用接口的联合类型,而不是带联合类型的接口。
评论
支持 Markdown