深入理解 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 | 全集 | 包含所有可能的值 |
有了这个模型,两个运算符的含义就一目了然:
集合并集:
集合交集:
关键洞察
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 的并集——只要值落在这两个集合中的任何一个里,就满足类型要求。
// 值可以是 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 在进行类型收窄之前,无法确定当前值具体是哪个成员类型,因此不会让我们访问只有部分成员才有的属性。
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 通过该字段自动收窄类型。
这个模式之所以叫"可辨识",正是因为我们通过一个特殊字段来「辨识」当前值的具体类型。就像每张身份证都有一个唯一的身份证号,通过这个号码我们就能确定是哪个人。
// 每个成员都有 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;有了辨识字段,类型收窄就变得自然且安全:
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 能确定联合类型的具体分支时,它会自动"收窄"到该分支类型——就像我们逐步缩小搜索范围一样,编译器也会逐步缩小可能的类型范围。以下五种收窄方式覆盖绝大多数场景:
// ---------- 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 类型,就证明所有可能的情况都已经被处理了。
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 在联合类型上的行为
这是一个容易被忽略但非常重要的细节:
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 可能没有 role2.6 分布式条件类型
当条件类型作用于裸类型参数(naked type parameter)且该参数是联合类型时,条件类型会分发到每个成员——这是 TypeScript 类型系统的一个独特机制,理解它能帮助我们写出更精妙的工具类型。
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[]type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;
type Result = ToArrayNoDistribute<string | number>;
// Result = (string | number)[]
// 注意:这是元素为联合类型的数组实践中最有用的分布式条件类型——从联合中排除特定成员:
// 从 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>; // string3. 交叉类型:类型的"与"
3.1 基础语法与语义
如果说联合类型代表「或」的关系,那么交叉类型代表的就是「且」——值必须同时满足所有成员类型。交叉类型使用 & 运算符,在集合运算中对应交集操作。
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 & Employee 是 Person ∩ Employee——只包含那些同时满足两种结构的对象。因为 TypeScript 是结构类型系统,一个新对象只要拥有所有要求的属性,它就属于这个交集。
3.2 属性冲突的本质
当交叉类型中存在同名但类型不同的属性时,结果可能会出乎意料:
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 函数类型的交叉 = 重载签名
这是一个容易被误解的特性。很多人会以为两个函数类型的交叉会让参数类型变成联合类型,但实际情况并非如此——两个函数类型的交叉,行为等同于函数重载:
// 两个函数类型的交叉:产生一个"既能处理 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 的并集:
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 类型的值同时拥有 User 和 Admin 的所有属性,所以你当然可以访问所有这些属性。
3.5 交叉类型 vs extends 继承
交叉类型和接口继承都能实现类型组合,但它们有本质区别:
定义时声明继承关系
- 显式父子层级,is-a 关系
- 属性冲突时编译报错(友好提示)
- 受限于继承链设计
- 适用:有明确层级关系的类型
使用时任意组合
- 无层级,纯组合
- 属性冲突时静默产生 never
- 可组合任意数量
- 适用:临时组合 / 扩展第三方类型
// 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 将联合映射为交叉,将交叉映射为联合。这种关系叫做「对偶性」,是数学中非常美妙的现象:
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 将联合与交叉互相翻转——联合的 key 是 key 的交叉(只能访问共有的),交叉的 key 是 key 的联合(拥有全部)。这是 TypeScript 类型系统中最优雅的数学关系之一。
直觉理解
- 对于联合类型
A | B,你只能访问两者共有的属性 → key 是交集 - 对于交叉类型
A & B,你拥有两者的全部属性 → key 是并集
4.2 分配律
和集合运算一样,类型的联合和交叉遵循分配律。这意味着我们可以在复杂类型表达式中自由地重新排列组合顺序,而不影响最终结果:
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)。联合和交叉与这两个特殊类型的交互,完全符合集合代数的直觉。理解这些关系,能帮助我们更深入地洞察类型系统的本质:
// ---------- 与 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 是交叉的单位元)这些看似抽象的关系,在实际编程中有直接应用:
// 利用 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 表达。这种模式让我们在类型层面就能区分操作的成功与失败,而不需要依赖运行时异常。
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(标记类型)
利用交叉类型创建名义上不同的类型,防止混淆。这是一种在类型层面区分语义不同但结构相同的数据的技巧。
// 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 组合模式
利用交叉类型组合多个独立的能力。这种模式让我们能够像搭积木一样,动态地组合各种功能模块。
// 每个 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 & SoftDeletable5.4 精确的组件 Props 类型
这是联合与交叉协作的典型场景——不同的 variant 有不同的属性约束。这种模式在设计 UI 组件时特别有用,既能保证类型安全,又能保持代码整洁。
// 公共属性
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 = A | never 是联合的单位元 |
A & unknown = A | unknown 是交叉的单位元 |
| 条件类型对裸类型参数分发 | F<A | B> = F<A> | F<B> |
在实践中,我们应该:
- 用可辨识联合处理"多种可能性"——配上
assertNever做穷尽性检查 - 用交叉类型组合独立的能力——尤其适用于扩展第三方类型
- 用 Result 模式让错误处理进入类型系统
- 用 Branded Type 区分结构相同但语义不同的类型
理解这两种类型的本质及其交互规律,是从"会用 TypeScript"到"驾驭 TypeScript 类型系统"的关键一步。
- 类型是值的集合——| 是并集(满足其一),& 是交集(全部满足)
- 用可辨识联合处理多态——配上 switch + assertNever 实现编译时穷尽性检查
- 条件类型对裸参数自动分发——这是 Exclude / Extract 等工具类型的底层原理
- 交叉类型组合能力,接口继承表达层级——灵活组合选 &,明确 is-a 选 extends
- keyof 对偶性、分配律、与 never/unknown 的代数关系——掌握这些,才能在复杂场景下推导类型行为