mcpField
T
tagged-unions

tagged-unions

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

关于 tagged-unions

面向 TypeScript 开发者的编码技能,指导将属性间存在依赖的接口改为带标签的接口联合。它强调用类型表达有效状态,并借助类型收窄和穷尽检查发现错误。

主要功能

  1. 01将依赖属性拆分为接口联合
  2. 02使用字面量标签区分变体
  3. 03利用类型收窄访问变体字段
  4. 04通过穷尽检查覆盖所有状态
  5. 05为外部数据定义内部类型

适用场景

  1. 01建模不同状态对应的数据
  2. 02避免相关可选字段出现不一致
  3. 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