Effective TypeScript

使用类型操作符和泛型避免重复

核心原则:应用 DRY 原则于类型定义

在软件开发中,遵循 DRY (Don't Repeat Yourself) 原则至关重要。类型定义中的重复(Type Duplication)会带来维护困难,增加代码在未来发生分歧(diverge)的风险。通过学习 TypeScript 提供的类型操作(Type Operations)和泛型(Generic Types),可以将 DRY 原则应用到类型定义中,从而实现类型定义的抽象化和同步化。

减少类型重复的基础方法

命名类型

避免重复的最简单方法是为类型结构赋予一个名称,并在需要的地方复用它。这相当于在类型系统中提取一个“常量”:

// 不推荐:重复的内联类型定义
function distance(a: {x: number, y: number}, b: {x: number, y: number}) {
  return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}

// 推荐:提取并命名类型
interface Point2D {
  x: number;
  y: number;
}
function distance(a: Point2D, b: Point2D) { /* ... */ } //

如果多个函数共享同一函数签名,应提取一个命名类型(遵循 Item 12 的建议)。

使用 extends 或交叉类型

当一个类型是另一个类型的超集时,可以使用 extends 关键字来消除重复,只写出新增的字段。如果多个类型共享一个字段子集,可以抽象出一个基类型(Base Interface)。

interface Person {
  firstName: string;
  lastName: string;
}

// 扩展接口
interface PersonWithBirthDate extends Person {
  birth: Date; // 只定义新增字段,避免重复
}

// 提取基础接口
interface Vertebrate {
  weightGrams: number;
  color: string;
}
interface Bird extends Vertebrate {
  wingspanCm: number;
} //

您也可以使用交叉类型操作符 & 来扩展现有类型,这在向联合类型添加属性时特别有用:

type PersonWithBirthDateAlt = Person & { birth: Date }; //

使用类型操作进行类型映射

当需要从现有类型派生出新类型时,可以使用类型操作工具。

索引访问类型(Indexing into Types)

可以使用索引访问类型(T['K'])来获取另一个类型中特定属性的类型,确保类型定义与源头保持同步。

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}

interface TopNavState {
  userId: State['userId'];       // 从 State 中继承类型
  pageTitle: State['pageTitle'];
  recentFiles: State['recentFiles'];
} //

索引访问类型也常用于从标签联合类型(Tagged Unions)中提取所有标签的联合类型,从而避免手动重复书写标签名称:

interface SaveAction { type: 'save'; }
interface LoadAction { type: 'load'; }
type Action = SaveAction | LoadAction;

// 提取所有 type 属性的联合类型:"save" | "load"
type ActionType = Action['type']; //

泛型工具类型和映射类型

泛型(Generic Types) 是类型层面的“函数”,它们接受类型参数并生成具体类型。映射类型(Mapped Types)则是一种在类型系统中的“循环”,允许遍历一个类型的键,并根据键和值创建新类型。keyof 操作符用于获取类型的所有键的联合类型。

内置的工具类型(Utility Types)利用这些机制帮助我们避免重复:

| 工具类型 | 目的 | 示例 |
| :--- | :--- | :--- |
| `Pick<T, K>` | 从类型 `T` 中选取属性 `K` 的子集来构建新类型。 | `type UserView = Pick<State, 'userId' | 'pageTitle'>;` |
| `Partial<T>` | 将类型 `T` 的所有属性标记为可选(Optional)。 | `type OptionsUpdate = Partial<Options>;` |
| `ReturnType<T>` | 获取函数类型 `T` 的返回值类型。 | `type UserInfo = ReturnType<typeof getUserInfo>;` |

映射类型的基本结构如下(例如实现 Partial):

// 遍历 Options 的所有键,并为每个键添加可选修饰符 (?)
type OptionsUpdate = {[k in keyof Options]?: Options[k]}; //

// 映射类型可以保留修饰符 (Homomorphic Mapped Types)
interface Customer {
  /** 客户姓名 */
  readonly name: string;
}
// Pick 是同构映射,会保留 readonly 修饰符和 TSDoc 文档
type PickName = Pick<Customer, 'name'>;

键重命名(Key Renaming)

映射类型还支持使用 as 子句来重命名属性键。这在处理键值转换或规范化时非常有用:

interface ShortToLong {
  q: 'search';
  n: 'numberOfResults';
}

// 将 ShortToLong 的值作为新类型的键,键作为新类型的值
type LongToShort = { [k in keyof ShortToLong as ShortToLong[k]]: k };
// LongToShort 的类型结果为 { search: "q"; numberOfResults: "n"; }

从运行时值推导类型

如果运行时值是某一类型的唯一“事实来源”(source of truth),可以使用 typeof 操作符从该值推导出其静态类型。

const INIT_OPTIONS = {
  width: 640,
  height: 480,
  color: '#00FF00',
  label: 'VGA',
};

// Options 的类型将精确匹配 INIT_OPTIONS 的结构 (例如 width: number)
type Options = typeof INIT_OPTIONS; 

避免过度抽象(Premature Abstraction)

虽然 DRY 原则很重要,但应避免不恰当的抽象。如果两个类型(例如 ProductCustomer)恰好共享一些结构相同的属性(如 id: number, name: string),但它们在语义上代表不同的概念,并且可能独立演变,则不应强制抽象出共同的基类型。

引用格言:“重复远比错误的抽象便宜得多”(duplication is far cheaper than the wrong abstraction)。


总结要点

  • DRY 原则同样适用于类型定义。应使用类型操作和泛型来避免在类型结构中重复编写代码。
  • 通过命名类型和使用 extends& 交叉类型来减少重复定义。
  • 泛型是类型层面的“函数”,用于抽象和重用类型逻辑。
  • 熟悉并使用内置的工具类型,如 PickPartialReturnType,来代替手动编写映射逻辑。
  • 掌握 keyoftypeof、索引访问和映射类型等工具,以实现类型之间的灵活转换。
  • 避免过度抽象:确保您共享的属性在语义上是相同的,而不是仅仅在结构上巧合一致。
在 GitHub 上编辑

上次更新于