《TypeScript编程入门》5.2 type 别名、联合与交叉

本节讲解 type 别名的定义方式与它相对 interface 的独有能力:给联合类型、交叉类型、元组、函数、字面量起名字。重点剖析联合类型(这个或那个)与交叉类型(这个并且那个)的语义差别、组合规律与常见陷阱,并用一个状态机模型演示两者如何协同表达业务约束。读完本节,你能用类型精确描述「多种可能」与「多项能力叠加」,并知道何时该用 type、何时该用 interface。

本节目标:掌握 type 别名的定义方式与它相对 interface 的独有能力;理解联合类型(|)与交叉类型(&)的语义差别、组合规律与典型陷阱;能用它们描述「多种可能」和「多项能力叠加」这两类真实业务约束。

5.2 type 别名、联合与交叉

上一节的 interface 擅长描述「一个对象有哪些字段」。但业务里还有两类需求它表达不了:一是「这个值可能是 A,也可能是 B」(比如接口既可能成功也可能失败),二是「这个类型同时具备 A 和 B 的全部能力」。本节要讲的 type 别名、联合类型与交叉类型,正是为此而生。

一、type 别名:给类型起个名字

type 的第一层用途非常简单——给任意类型起一个可复用的名字。

type UserId = string;
type Point = { x: number; y: number };
type Callback = (err: Error | null, data?: string) => void;
type Pair = [number, number];
type Status = 'idle' | 'loading' | 'done';

语法是 type 名字 = 类型表达式;。等号右边可以是任意合法的类型,包括原始类型、对象、函数、元组、联合、交叉、条件类型等等。这是 type 相对 interface 最大的优势:interface 只能描述对象结构,而 type 可以为任何类型起名。

一个常见的直觉误区是「type 和 interface 是两种不同的类型系统」。其实不是。它们产出的都是「类型」,在很多场景下可以互相替换:

// 这两种写法在大多数使用场景下等价
interface PointA { x: number; y: number }
type PointB = { x: number; y: number };

差别集中在三点,我们逐一对比:

能力interfacetype
描述对象结构支持支持
描述联合 / 交叉 / 元组 / 函数不支持支持
extends 继承支持用交叉 & 模拟
声明合并(同名自动合并)支持不支持(重名报错)
implements 被类实现支持支持(对象形状即可)
错误信息可读性通常更好复杂类型时可能很长

所以业界流传的口诀是:描述对象用 interface,其余一律 type。需要给第三方库打补丁(依赖声明合并)时,必须用 interface;需要表达联合、元组、条件类型时,必须用 type。本书 5.3 节会从结构化类型的角度再深挖一层取舍逻辑。

二、联合类型:这个或那个

联合类型用竖线 | 表示「满足其中之一即可」。

type Id = string | number;

function printId(id: Id): void {
  console.log(`ID: ${id}`);
}

printId(101);      // OK
printId('a-101');  // OK

语义上,「A | B」的意思是:这个值要么是 A,要么是 B。关键在于——在类型收窄之前,你只能访问两者共有的成员。

type Cat = { meow(): void; legs: number };
type Dog = { bark(): void; legs: number };

function speak(pet: Cat | Dog): void {
  console.log(pet.legs); // OK:legs 是共有属性
  pet.meow();            // 报错:Property 'meow' does not exist on type 'Dog'.
}

legs 两边都有,所以可以直接访问;meow 只有 Cat 有,所以在收窄之前不能碰。想访问 meow,必须先判断它到底是不是 Cat:

function speak2(pet: Cat | Dog): void {
  if ('meow' in pet) {
    pet.meow();  // 这里 pet 被收窄为 Cat
  } else {
    pet.bark();  // 这里 pet 被收窄为 Dog
  }
}

这就是「控制流分析」在起作用:TypeScript 会根据 in、typeof、instanceof、字面量比较等条件,自动缩小联合类型的范围。这套机制的完整规则在 7.3 节展开,本节先建立「联合类型必须收窄后才能用」的直觉。

三、字面量类型与联合:最实用的组合

联合类型最广泛的应用,是把一组字面量组合成「枚举式」的取值约束。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Direction = 'up' | 'down' | 'left' | 'right';

function request(url: string, method: HttpMethod): void {
  // ...
}

request('/api/users', 'GET');    // OK
request('/api/users', 'FETCH');  // 报错:Argument of type '"FETCH"' is not
                                 // assignable to parameter of type 'HttpMethod'.

相比枚举(enum),字符串字面量联合有几个明显优势:编译后零运行时代码、与 JSON 天然兼容、编辑器能直接提示所有合法取值。所以在现代 TypeScript 工程里,'a' | 'b' | 'c' 的使用频率远高于 enum。3.2 节讲过的元组与枚举,这里可以对照理解。

四、交叉类型:这个并且那个

交叉类型用 & 表示「同时满足所有成员」。

type HasName = { name: string };
type HasAge = { age: number };

type Person = HasName & HasAge;

const p: Person = { name: 'Ada', age: 36 }; // 两个字段都必须有

直觉上可以理解为「集合的交集」:A & B 的值必须同时是 A 也是 B,所以对象类型交叉的结果就是成员合并。这与联合类型的直觉刚好相反,务必区分清楚:

运算符读法对象类型的结果值的约束
A | B或只保证共有成员可访问满足任一即可
A & B且合并全部成员必须全部满足

交叉类型最常见的用途是「能力叠加」,比如把多个小接口拼成一个完整对象类型:

type Timestamps = { createdAt: Date; updatedAt: Date };
type SoftDeletable = { deletedAt: Date | null };

type Article = {
  id: string;
  title: string;
} & Timestamps & SoftDeletable;

它也是 interface extends 的等价替代方案,而且能交叉「非对象类型」(比如把函数类型与属性交叉,做出「可调用的对象」):

type Logger = ((msg: string) => void) & { level: 'info' | 'warn' | 'error' };

declare const log: Logger;
log('hello');        // 可调用
log.level = 'warn';  // 也有属性

五、联合与交叉的组合规律

当联合与交叉混用时,记住两条分配律,就能读懂绝大多数复杂类型:

// 交叉对联合有分配律:A & (B | C) 等价于 (A & B) | (A & C)
type R1 = { a: 1 } & ({ b: 2 } | { c: 3 });
// 等价于 { a: 1; b: 2 } | { a: 1; c: 3 }

这条规律在写「公共字段 + 多种变体」的类型时特别有用:

type BaseEvent = { id: string; timestamp: number };
type UserEvent =
  | (BaseEvent & { kind: 'login'; userId: string })
  | (BaseEvent & { kind: 'logout'; userId: string })
  | (BaseEvent & { kind: 'purchase'; amount: number });

注意这里的 kind 字段——每个变体都有一个取值不同的字面量字段,这构成了「可辨识联合」的基础。它是 TypeScript 里表达「有限状态」最强力的工具,7.2 节会用一整节来讲。

六、一个经典陷阱:交叉「不可能类型」

& 在对象类型上是合并,但在原始类型上会取交集——而交集可能是空的:

type Impossible = string & number;

const x: Impossible = 'hi'; // 报错:Type 'string' is not assignable to type 'never'.

string & number 的结果是 never,因为不存在一个值同时是字符串和数字。这类错误往往出现在「把两个语义冲突的联合交叉」时:

type A = { type: 'a'; value: string };
type B = { type: 'b'; value: number };

type AB = A & B;
// type 字段被推断为 'a' & 'b',即 never
// 于是 AB 整体退化成 never,任何赋值都会失败

识别信号是:报错信息里突然出现 never。看到 never 时,第一反应应该是「是不是交叉了两个互斥的字面量」。这在 7.2 节判别联合的进阶用法里会反复出现,属于必须建立的条件反射。

七、联合类型上的「属性访问」陷阱

另一个高频坑:联合类型上访问一个「不是所有成员都有」的属性时,报错信息有时会让人误以为类型没生效。

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  return Math.PI * s.radius ** 2;
  // 报错:Property 'radius' does not exist on type 'Shape'.
  //       Property 'radius' does not exist on type '{ kind: "square"; side: number; }'.
}

正确写法是先按 kind 收窄:

function area2(s: Shape): number {
  switch (s.kind) {
    case 'circle':
      return Math.PI * s.radius ** 2;
    case 'square':
      return s.side ** 2;
  }
}

在 strict 模式下,如果 switch 没有覆盖所有 kind,TypeScript 还能通过返回类型报错提示你「漏了分支」。这正是把 never 用作「穷尽性检查」的经典手法,我们留到 7.3 节展开。

八、工程示例:用联合与交叉描述订单状态

把本节知识落到一个真实场景——电商订单状态机:

type OrderId = string;
type Money = { amount: number; currency: 'CNY' | 'USD' };

// 所有状态共享的基础字段
type OrderBase = {
  id: OrderId;
  createdAt: Date;
  total: Money;
};

// 每种状态各自独有的字段
type PendingOrder = OrderBase & { status: 'pending' };
type PaidOrder = OrderBase & { status: 'paid'; paidAt: Date; txId: string };
type ShippedOrder = OrderBase & {
  status: 'shipped';
  trackingNo: string;
  carrier: 'SF' | 'JD' | 'EMS';
};
type CancelledOrder = OrderBase & { status: 'cancelled'; reason: string };

type Order = PendingOrder | PaidOrder | ShippedOrder | CancelledOrder;

function describe(order: Order): string {
  switch (order.status) {
    case 'pending':
      return `订单 ${order.id} 待支付,金额 ${order.total.amount}`;
    case 'paid':
      return `订单 ${order.id} 已支付,流水号 ${order.txId}`;
    case 'shipped':
      return `订单 ${order.id} 已发货,${order.carrier} ${order.trackingNo}`;
    case 'cancelled':
      return `订单 ${order.id} 已取消:${order.reason}`;
  }
}

这个模型的价值在于:每个状态下「哪些字段可用」是编译器强制的。在 pending 分支里你写不出 order.trackingNo,因为该状态根本没有这个字段——这从根源上消灭了「用错状态字段」这类 bug。

如果把类型进一步抽象,还可以给联合与交叉配上泛型约束(8.1 节)与工具类型(10.1 节),比如用 Extract 从 Order 里挑出所有「已支付」状态:

type PaidStates = Extract<Order, { status: 'paid' | 'shipped' }>;
// PaidOrder | ShippedOrder

想继续深入联合与交叉的高级玩法,可以延伸阅读 TypeScript 高级类型 与 GraphQL 联合与接口设计 ——后者从 API 建模的角度讨论了同一类思想。

九、本节要点速查

语法含义典型用途
type X = ...类型别名给任意类型起名
A | B联合「这个或那个」
A & B交叉「这个并且那个」
'a' | 'b'字面量联合取值约束(替代枚举)
T & (A | B)分配律公共字段 + 多变体
string & number交集为空结果是 never(陷阱)

小结

本节补齐了 interface 之外的另一半拼图:

  1. type 别名可以为任何类型起名,能力上覆盖 interface,但不支持声明合并。
  2. 联合类型 | 表达「或」,使用前必须收窄;交叉类型 & 表达「且」,在对象上表现为成员合并。
  3. 两者组合的分配律是读懂复杂类型的钥匙,而「交叉互斥字面量得到 never」是最需要警惕的陷阱。

现在,interface 与 type 在语法层面我们都已经掌握了,但「到底该选哪个」还没有一个稳定的判断标准。更重要的是,我们一直在说「结构对上就行」——为什么 TypeScript 只比较结构,而不像 Java、C# 那样比较类型名字? 这背后是 TypeScript 最核心的设计决策之一。下一节 结构化类型与两者取舍 将正面回答这两个问题。

阅读导航:上一节:5.1 interface 定义对象结构 · 下一节:5.3 结构化类型与两者取舍 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes