Skip to content

深入理解 TypeScript 联合类型与交叉类型

摘要: 以类型是值的集合这一核心心智模型为出发点,系统阐述了 TypeScript 联合类型与交叉类型的语法语义及深层设计原理。联合类型部分涵盖基础语法、可辨识联合模式、五种类型收窄方式、基于 never 的穷尽性检查、keyof 在联合类型上的行为及分布式条件类型的分发机制。交叉类型部分则解析了属性冲突的产生原因、函数类型交叉与重载签名的等价性,以及交叉类型与接口继承在时机、冲突处理和适用场景上的本质区别。进一步揭示了 keyof 在联合与交叉之间的对偶性、类型运算的分配律,以及与 never 和 unknown 的代数关系。最后以 Result/Either 模式、Branded Type、Mixin 组合及组件 Props 类型设计四个实战模式展示联合与交叉在工程中的应用。

1. 前置概念:类型即集合

理解联合类型和交叉类型的关键在于建立一个核心心智模型:类型是值的集合

这个看似简单的隐喻,实际上是我们理解一切复杂类型操作的起点。当我们说一个变量的类型是 string 时,不妨把它想象成「所有可能字符串值」这个无穷集合。而 TypeScript 的类型检查,本质上就是判断某个具体的值是否落在类型所对应的集合之中。

类型集合含义说明
string所有字符串值的集合无限集
number所有数字值的集合无限集
"a" | "b"仅含 "a""b" 的集合有限集,含 2 个元素
never空集不包含任何值
unknown全集包含所有可能的值

有了这个模型,两个运算符的含义就一目了然:

集合并集:

AB=ABA \mid B \;=\; A \cup B

集合交集:

A&B=ABA \& B \;=\; A \cap B

关键洞察

string | number 是所有字符串 + 所有数字的集合——值只要属于其中之一即可。 string & number 是既属于字符串又属于数字的值的集合——不存在这样的值,所以结果是 never(空集)。

这个「类型即集合」的视角将贯穿全文。下面我们分别深入联合类型与交叉类型,再从类型系统的高度审视二者的深层关系。

💡关键要点
  • 类型是值的集合——联合类型 (|) 是并集,交叉类型 (&) 是交集
  • 可辨识联合是 TypeScript 中最接近代数数据类型(ADT)的表达,配上 assertNever 做穷尽性检查
  • keyof 在联合与交叉上构成对偶关系:keyof(A|B) = keyof A & keyof B,反之亦然
  • 条件类型对裸类型参数自动分发:F<A|B> = F<A> | F<B>,这是实现 Exclude/Extract 的基础
  • 实战中 Result 模式让错误处理进入类型系统,Branded Type 零成本区分语义不同的类型

2. 联合类型:类型的"或"

2.1 基础语法与语义

联合类型使用 | 运算符,表示值属于任一成员类型。从集合的角度来看,A | B 就是集合 A 和集合 B 的并集——只要值落在这两个集合中的任何一个里,就满足类型要求。

typescript
// 值可以是 string 或 number——集合的视角:string 集 ∪ number 集
let value: string | number;

value = "hello"; // ✅ string 是联合成员
value = 42;       // ✅ number 也是联合成员
// value = true;  // ❌ boolean 不在集合中

// 字面量联合:精确限定取值空间
type Status = "success" | "error" | "loading";
// Status 是一个有限集:{ "success", "error", "loading" }

联合类型有一个核心约束需要特别注意:只能安全访问所有成员共有的属性。这是因为 TypeScript 在进行类型收窄之前,无法确定当前值具体是哪个成员类型,因此不会让我们访问只有部分成员才有的属性。

typescript
interface Bird { fly(): void; sing(): void; }
interface Fish { swim(): void; sing(): void; }

declare function getPet(): Bird | Fish;

const pet = getPet();
pet.sing(); // ✅ sing 同时存在于 Bird 和 Fish
// pet.fly();  // ❌ fly 只存在于 Bird,直接访问不安全

2.2 可辨识联合(Discriminated Union)

可辨识联合是联合类型最重要的设计模式,也是 TypeScript 中处理多态数据的推荐方式。它的核心思想是:每个成员拥有一个共同的、值唯一的字面量字段(称为"辨识字段"或 tag),TypeScript 通过该字段自动收窄类型。

这个模式之所以叫"可辨识",正是因为我们通过一个特殊字段来「辨识」当前值的具体类型。就像每张身份证都有一个唯一的身份证号,通过这个号码我们就能确定是哪个人。

typescript
// 每个成员都有 kind 字段,且值唯一——这是实现可辨识联合的关键
interface Circle {
    kind: "circle";   // 辨识字段
    radius: number;
}

interface Rectangle {
    kind: "rectangle"; // 辨识字段
    width: number;
    height: number;
}

interface Triangle {
    kind: "triangle";  // 辨识字段
    base: number;
    height: number;
}

type Shape = Circle | Rectangle | Triangle;

有了辨识字段,类型收窄就变得自然且安全:

typescript
function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            // shape 自动收窄为 Circle——radius 可直接访问
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            // shape 自动收窄为 Rectangle
            return shape.width * shape.height;
        case "triangle":
            // shape 自动收窄为 Triangle
            return (shape.base * shape.height) / 2;
    }
}

为什么可辨识联合如此重要

这是 TypeScript 中最接近代数数据类型(ADT)的表达方式。它把"类型的结构"和"类型的标识"统一在同一个字段中,让编译器能在每个分支中确定地知道当前类型,从而提供完整的类型安全保障。

2.3 类型收窄全解

当 TypeScript 能确定联合类型的具体分支时,它会自动"收窄"到该分支类型——就像我们逐步缩小搜索范围一样,编译器也会逐步缩小可能的类型范围。以下五种收窄方式覆盖绝大多数场景:

typescript
// ---------- 1. typeof 收窄(基础类型)----------
function format(value: string | number | bigint): string {
    if (typeof value === "string") {
        return value.toUpperCase();   // value: string
    }
    if (typeof value === "number") {
        return value.toFixed(2);      // value: number
    }
    return value.toString();           // value: bigint(剩余分支自动推断)
}

// ---------- 2. instanceof 收窄(类实例)----------
class ApiError extends Error {
    constructor(public statusCode: number) { super(); }
}

function handleError(err: Error | ApiError) {
    if (err instanceof ApiError) {
        console.log(`HTTP ${err.statusCode}`); // err: ApiError
        return;
    }
    console.log(err.message);                  // err: Error
}

// ---------- 3. in 收窄(属性存在性检查)----------
interface Dog { bark(): void; name: string; }
interface Cat { meow(): void; name: string; }

function interact(animal: Dog | Cat) {
    if ("bark" in animal) {
        animal.bark(); // animal: Dog
    } 
    else {
        animal.meow(); // animal: Cat
    }
}

// ---------- 4. 字面量 / 辨识字段收窄 ----------
// 如 2.2 中的 area() 函数,switch shape.kind

// ---------- 5. 自定义类型守卫 ----------
// 当内置收窄不够用时,手动定义收窄逻辑
function isDog(animal: Dog | Cat): animal is Dog {
    // "animal is Dog" 是类型谓词——告诉编译器返回 true 时 animal 就是 Dog
    return "bark" in animal;
}

if (isDog(animal)) {
    animal.bark(); // animal: Dog——编译器信任你的判断
}

2.4 穷尽性检查:never 的妙用

当我们处理联合类型时,最怕的就是遗漏某些分支——新增了一个联合成员,却忘记在某个 switch 或 if-else 中处理它。遗憾的是,TypeScript 默认不会对此发出警告。never 类型正是解决这一问题的利器。

never 是 TypeScript 中的「底类型」,代表一个不可能存在的值。如果我们能确保某个值确实是 never 类型,就证明所有可能的情况都已经被处理了。

typescript
function assertNever(value: never): never {
    throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function areaExhaustive(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            return shape.width * shape.height;
        default:
            return assertNever(shape);
    }
}

原理:在 default 分支中,shape 应该已被穷尽所有可能——即类型为 never。如果遗漏了任何分支,shape 不再是 never(而是被遗漏的那个类型),传给 assertNever(shape) 时编译器会报类型错误。这就是编译时的穷尽性检查。

2.5 keyof 在联合类型上的行为

这是一个容易被忽略但非常重要的细节:

typescript
interface User { id: number; name: string; email: string; }
interface Admin { id: number; name: string; role: string; }

// keyof 作用于联合类型:只取**共有**的 key
type CommonKeys = keyof (User | Admin); // "id" | "name"
// 因为对于一个 User | Admin 的值,你只能安全访问 id 和 name

// 这与直觉一致:你只能访问所有成员都有的属性
declare const entity: User | Admin;
entity.id;   // ✅
entity.name; // ✅
// entity.email; // ❌ Admin 可能没有 email
// entity.role;  // ❌ User 可能没有 role

2.6 分布式条件类型

当条件类型作用于裸类型参数(naked type parameter)且该参数是联合类型时,条件类型会分发到每个成员——这是 TypeScript 类型系统的一个独特机制,理解它能帮助我们写出更精妙的工具类型。

分发(裸类型参数)ToArray<T> — 裸参数,自动分发
type ToArray<T> = T extends any ? T[] : never;

// 分发过程:ToArray<string | number>
//   = ToArray<string> | ToArray<number>
//   = string[] | number[]
type Result = ToArray<string | number>;
// Result = string[] | number[]
不分发(用元组包裹)ToArrayNoDistribute<T> — 元组包裹,不分发
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;

type Result = ToArrayNoDistribute<string | number>;
// Result = (string | number)[]
// 注意:这是元素为联合类型的数组

实践中最有用的分布式条件类型——从联合中排除特定成员:

typescript
// 从 T 中排除能赋值给 U 的类型
type MyExclude<T, U> = T extends U ? never : T;

type Status = "success" | "error" | "loading";
type NonLoadingStatus = MyExclude<Status, "loading">; // "success" | "error"

// 另一个常用模式:从联合中提取特定成员
type MyExtract<T, U> = T extends U ? T : never;
type StringKeys = MyExtract<string | number | boolean, string>; // string

3. 交叉类型:类型的"与"

3.1 基础语法与语义

如果说联合类型代表「或」的关系,那么交叉类型代表的就是「且」——值必须同时满足所有成员类型。交叉类型使用 & 运算符,在集合运算中对应交集操作。

typescript
interface Person   { name: string; age: number; }
interface Employee { id: number; department: string; }

// Person & Employee:值必须同时包含 Person 和 Employee 的所有属性
type EmployeeRecord = Person & Employee;

const alice: EmployeeRecord = {
    name: "Alice",
    age: 30,
    id: 101,
    department: "Engineering",
};
// ✅ 所有属性缺一不可——这正是"交叉"的含义

从集合角度理解:Person & EmployeePerson ∩ Employee——只包含那些同时满足两种结构的对象。因为 TypeScript 是结构类型系统,一个新对象只要拥有所有要求的属性,它就属于这个交集。

3.2 属性冲突的本质

当交叉类型中存在同名但类型不同的属性时,结果可能会出乎意料:

typescript
interface A { value: number; shared: string; }
interface B { value: string; shared: string; }

// 对于 shared(类型相同):shared 的类型是 string & string = string
// 对于 value(类型不同):value 的类型是 number & string = never
type Conflict = A & B;
// Conflict = { value: never; shared: string }

// 因此,没有任何值能满足 value: never
// const x: Conflict = { value: ???, shared: "ok" }; // ❌ 无法赋值

本质原因:对于同名属性,TypeScript 会对属性类型执行交叉操作。如果交叉结果为 never(空集),则该属性无法被赋值。

注意

这与接口继承的行为不同——interface C extends A, B {} 在属性类型冲突时会直接编译报错,而不是静默产生 never。交叉类型更"宽容",它在类型层面保留冲突结果,直到你尝试赋值时才暴露问题。

3.3 函数类型的交叉 = 重载签名

这是一个容易被误解的特性。很多人会以为两个函数类型的交叉会让参数类型变成联合类型,但实际情况并非如此——两个函数类型的交叉,行为等同于函数重载

typescript
// 两个函数类型的交叉:产生一个"既能处理 number、又能处理 string"的函数
type OverloadedAdd = ((a: number, b: number) => number)
                  & ((a: string, b: string) => string);

// 上面的类型等价于一个有多个调用签名的函数:
// {
//   (a: number, b: number): number;
//   (a: string, b: string): string;
// }

const add: OverloadedAdd = (a: number | string, b: number | string): any => {
    if (typeof a === "number" && typeof b === "number") return a + b;
    if (typeof a === "string" && typeof b === "string") return a + b;
    throw new Error("Mismatched types");
};

const n = add(1, 2);        // n: number ✅
const s = add("Hello, ", "World"); // s: string ✅
// add(1, "2");             // ❌ 没有匹配的重载签名

原理:函数类型的交叉在 TypeScript 内部使用调用签名的合并(overload list concatenation),而非参数和返回值的交叉。因此 ((a: number) => number) & ((a: string) => string) 产生的是一个同时支持两种参数类型的函数。

3.4 keyof 在交叉类型上的行为

与联合类型相反,交叉类型的 keyof 结果是所有 key 的并集:

typescript
interface User  { id: number; name: string; }
interface Admin { id: number; role: string; }

// keyof 作用于交叉类型:取**所有** key 的并集
type AllKeys = keyof (User & Admin); // "id" | "name" | "role"
//                                 注意:是所有的 key,不只是共有的

这是因为 User & Admin 类型的值同时拥有 UserAdmin 的所有属性,所以你当然可以访问所有这些属性。

3.5 交叉类型 vs extends 继承

交叉类型和接口继承都能实现类型组合,但它们有本质区别:

两种类型组合方式
extends(接口继承)

定义时声明继承关系

  • 显式父子层级,is-a 关系
  • 属性冲突时编译报错(友好提示)
  • 受限于继承链设计
  • 适用:有明确层级关系的类型
vs
&(交叉类型)

使用时任意组合

  • 无层级,纯组合
  • 属性冲突时静默产生 never
  • 可组合任意数量
  • 适用:临时组合 / 扩展第三方类型
typescript
// extends:明确的层级关系——"AdminUser 是一个 User"
interface User     { id: number; name: string; }
interface AdminUser extends User { role: string; }

// &:灵活组合——"我需要一个同时有 User 和 WithTimestamp 的东西"
interface WithTimestamp { createdAt: Date; updatedAt: Date; }
type TimestampedUser = User & WithTimestamp;

// & 的不可替代场景:扩展第三方类型
type EnhancedWindow = Window & {
    __CUSTOM_GLOBAL__: string;
};

4. 联合与交叉的深层关系

4.1 keyof 对偶性

这是联合与交叉最优雅的数学关系——keyof 将联合映射为交叉,将交叉映射为联合。这种关系叫做「对偶性」,是数学中非常美妙的现象:

typescript
interface A { a: string; shared: number; }
interface B { b: string; shared: number; }

// 规律:keyof 将"类型的联合/交叉"翻转
type K1 = keyof (A | B); // "shared"        = keyof A & keyof B(取共有)
type K2 = keyof (A & B); // "a" | "b" | "shared" = keyof A | keyof B(取全部)

用集合语言表述这一对偶性:

keyof(AB)=keyof(A)&keyof(B)keyof(A \mid B) = keyof(A)\;\&\;keyof(B)

keyof(A&B)=keyof(A)keyof(B)keyof(A\;\&\;B) = keyof(A)\mid keyof(B)

💡

keyof 将联合与交叉互相翻转——联合的 key 是 key 的交叉(只能访问共有的),交叉的 key 是 key 的联合(拥有全部)。这是 TypeScript 类型系统中最优雅的数学关系之一。

直觉理解

  • 对于联合类型 A | B,你只能访问两者共有的属性 → key 是交集
  • 对于交叉类型 A & B,你拥有两者的全部属性 → key 是并集

4.2 分配律

和集合运算一样,类型的联合和交叉遵循分配律。这意味着我们可以在复杂类型表达式中自由地重新排列组合顺序,而不影响最终结果:

(AB)&C=(A&C)(B&C)(A\mid B)\;\&\;C \quad=\quad (A\;\&\;C)\mid(B\;\&\;C)

typescript
interface Base   { enabled: boolean; }
interface Config { timeout: number; }
interface Cache   { ttl: number; }

// 分配律验证
// (Config | Cache) & Base
//   = (Config & Base) | (Cache & Base)
//   = { timeout: number; enabled: boolean } | { ttl: number; enabled: boolean }

type Left  = (Config | Cache) & Base;
type Right = (Config & Base) | (Cache & Base);
// Left 和 Right 是等价的类型

理解分配律有助于简化复杂的类型表达式,也解释了为什么 (DatabaseConfig | ApiConfig) & BaseConfig 能让每个分支都获得 BaseConfig 的属性。

4.3 与类型层级的代数关系

在 TypeScript 的类型层级中,never 是底类型(bottom type),unknown 是顶类型(top type)。联合和交叉与这两个特殊类型的交互,完全符合集合代数的直觉。理解这些关系,能帮助我们更深入地洞察类型系统的本质:

typescript
// ---------- 与 never 的关系 ----------
// never 是空集,是联合的"单位元"(identity)
type T1 = string | never;  // string——添加空集不改变原集合
type T2 = string & never;  // never——空集与任何东西的交集是空集

// ---------- 与 unknown 的关系 ----------
// unknown 是全集,是交叉的"单位元"
type T3 = string | unknown; // unknown——任何集合 ∪ 全集 = 全集
type T4 = string & unknown; // string——任何集合 ∩ 全集 = 该集合本身

// ---------- 推论:联合与交叉的零等律 ----------
// A | never = A       (never 是联合的单位元)
// A & never = never   (never 是交叉的零元)
// A | unknown = unknown (unknown 是联合的零元)
// A & unknown = A       (unknown 是交叉的单位元)

这些看似抽象的关系,在实际编程中有直接应用:

typescript
// 利用 A | never = A:安全地从联合中移除成员
type Remove<T, U> = T extends U ? never : T;
// 不匹配的成员保留原类型(A | never = A),匹配的变成 never 后被"吸收"

type Result = Remove<"a" | "b" | "c", "b">; // "a" | "c"
// 分发过程:
// Remove<"a", "b"> | Remove<"b", "b"> | Remove<"c", "b">
// = "a" | never | "c"
// = "a" | "c"           ← never 被单位元法则消除

5. 实战模式

5.1 Result / Either 模式

将成功和失败编码为可辨识联合——这是函数式编程中 Either 模式的 TypeScript 表达。这种模式让我们在类型层面就能区分操作的成功与失败,而不需要依赖运行时异常。

typescript
type Result<T, E = Error> =
    | { success: true; data: T }      // 成功分支
    | { success: false; error: E };   // 失败分支

// 使用 Result 的函数签名比抛出异常更"诚实"——
// 调用方一看就知道需要处理两种结果
async function fetchUser(id: number): Promise<Result<User>> {
    try {
        const user = await db.users.find(id);
        return { success: true, data: user };
    } catch (e) {
        return { success: false, error: e as Error };
    }
}

// 消费方通过辨识字段自动收窄
const result = await fetchUser(1);
if (result.success) {
    console.log(result.data.name);  // result: { success: true; data: User }
} else {
    console.error(result.error.message); // result: { success: false; error: Error }
}

为什么比 try-catch 更好

异常会中断控制流且类型系统中无法追踪"这个函数可能抛出什么"。Result 模式将错误提升为一等公民——错误路径和成功路径一样,在类型层面是可见的、可追踪的。

5.2 Branded Type(标记类型)

利用交叉类型创建名义上不同的类型,防止混淆。这是一种在类型层面区分语义不同但结构相同的数据的技巧。

typescript
// Brand 是一个不可见的标记——用来区分结构相同但语义不同的类型
type Brand<T, BrandName extends string> = T & { readonly __brand: BrandName };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

// UserId 和 OrderId 的运行时结构完全相同(都是 string),
// 但类型层面不可互换——防止将 UserId 误传给期望 OrderId 的函数

function getUser(id: UserId): User { /* ... */ }

const userId  = "usr_123" as UserId;
const orderId = "ord_456" as OrderId;

getUser(userId);  // ✅
// getUser(orderId); // ❌ OrderId 不能赋值给 UserId

原理解释string & { __brand: "UserId" } 在运行时只是一个 string__brand 字段实际上不存在),但在编译时它是一个不同的类型。这实现了零运行时成本的类型安全。

5.3 Mixin 组合模式

利用交叉类型组合多个独立的能力。这种模式让我们能够像搭积木一样,动态地组合各种功能模块。

typescript
// 每个 Mixin 是一个独立的"能力模块"
type Timestamped = { createdAt: Date; updatedAt: Date };
type SoftDeletable = { deletedAt: Date | null; isDeleted: boolean };
type Versioned = { version: number };

// 交叉类型组合——按需装配能力
type BaseEntity = { id: number; name: string };
type FullEntity = BaseEntity & Timestamped & SoftDeletable & Versioned;

// 你也可以用构造函数模式动态组合
type Constructor<T = {}> = new (...args: any[]) => T;

function withTimestamp<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        createdAt: Date = new Date();
        updatedAt: Date = new Date();
    };
}

function withSoftDelete<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        deletedAt: Date | null = null;
        get isDeleted() { return this.deletedAt !== null; }
    };
}

// 动态装配——运行时组合 + 编译时类型推导
class Entity { id = 0; name = ""; }
const EnhancedEntity = withSoftDelete(withTimestamp(Entity));
// 类型:Entity & Timestamped & SoftDeletable

5.4 精确的组件 Props 类型

这是联合与交叉协作的典型场景——不同的 variant 有不同的属性约束。这种模式在设计 UI 组件时特别有用,既能保证类型安全,又能保持代码整洁。

typescript
// 公共属性
interface BaseProps {
    onClick: () => void;
    disabled?: boolean;
}

// 各 variant 专属属性
interface PrimaryVariant   { variant: "primary";   size?: "sm" | "md" | "lg"; }
interface SecondaryVariant { variant: "secondary"; outline?: boolean; }
interface DangerVariant    { variant: "danger";    confirmText?: string; }

// 联合 × 交叉:BaseProps & (Primary | Secondary | Danger)
type ButtonProps = BaseProps & (PrimaryVariant | SecondaryVariant | DangerVariant);

// 使用时,通过 variant 收窄后,专属属性自动可用
function Button(props: ButtonProps) {
    if (props.variant === "primary") {
        props.size; // ✅ size 只在 PrimaryVariant 上存在
    }
    if (props.variant === "danger") {
        props.confirmText; // ✅ confirmText 只在 DangerVariant 上存在
    }
}

6. 总结

联合类型和交叉类型本质上对应集合的。掌握这个模型,一切行为都可以从数学本质上推导出来:

规则说明
A | B = 集的并值满足 A 或 B 即可
A & B = 集的交值必须同时满足 A 和 B
keyof(A | B) = keyof A & keyof B联合的 key 是 key 的交叉(取共有)
keyof(A & B) = keyof A | keyof B交叉的 key 是 key 的联合(取全部)
A | never = Anever 是联合的单位元
A & unknown = Aunknown 是交叉的单位元
条件类型对裸类型参数分发F<A | B> = F<A> | F<B>

在实践中,我们应该:

  • 可辨识联合处理"多种可能性"——配上 assertNever 做穷尽性检查
  • 交叉类型组合独立的能力——尤其适用于扩展第三方类型
  • Result 模式让错误处理进入类型系统
  • Branded Type 区分结构相同但语义不同的类型

理解这两种类型的本质及其交互规律,是从"会用 TypeScript"到"驾驭 TypeScript 类型系统"的关键一步。

核心结论
  • 类型是值的集合——| 是并集(满足其一),& 是交集(全部满足)
  • 用可辨识联合处理多态——配上 switch + assertNever 实现编译时穷尽性检查
  • 条件类型对裸参数自动分发——这是 Exclude / Extract 等工具类型的底层原理
  • 交叉类型组合能力,接口继承表达层级——灵活组合选 &,明确 is-a 选 extends
  • keyof 对偶性、分配律、与 never/unknown 的代数关系——掌握这些,才能在复杂场景下推导类型行为