文章

联合类型操作深度解析

联合类型操作深度解析

一句话概括

联合类型(A | B)和交叉类型(A & B)是 TypeScript 类型系统中”或”与”且”的运算符,而联合转交叉、联合转元组、IsUnion 类型判断则是利用分布式条件类型函数参数逆变特性实现的高级类型体操。

一、背景与意义

1.1 联合类型是什么?

联合类型(Union Type)表示一个值可以是几种类型之一:

1
2
3
type Status = 'idle' | 'loading' | 'success' | 'error';
type Result = string | number | boolean;
type AsyncState = { status: 'loading' } | { status: 'success'; data: any } | { status: 'error'; error: Error };

联合类型在 TypeScript 中无处不在,但它的行为有一些反直觉的特性:

只有共同属性才能被安全访问

1
2
3
4
5
6
7
8
interface Bird { fly(): void; layEggs(): void; }
interface Fish { swim(): void; layEggs(): void; }
type Pet = Bird | Fish;

function getPet(): Pet { /* ... */ }
const pet = getPet();
pet.layEggs(); // ✅ 共同方法
pet.fly();     // ❌ Error: Fish 上没有 fly

窄化(Narrowing)是访问特有属性的唯一方式

1
2
3
if ('fly' in pet) {
  pet.fly(); // ✅ 窄化后 Bird
}

1.2 为什么需要操作联合类型?

联合类型的基本操作(联合 | 和交叉 &)是类型定义层面的操作。但在实际编程中,我们需要更精细的控制:

  1. 联合 → 交叉:将 {a: number} | {b: string} 转化为 {a: number} & {b: string}(用于某些特殊场景的类型适配)
  2. 联合 → 元组:将 'a' | 'b' | 'c' 转化为 ['a', 'b', 'c'](用于维持顺序的联合类型处理)
  3. 判断是否是联合IsUnion<string | number>trueIsUnion<string>false(用于泛型约束中的联合类型检测)

这些操作在类型编程(类型体操)中经常出现,配合条件类型、映射类型和 infer 可以实现非常强大的类型变换。

二、概念与定义

2.1 联合类型(Union Types)

联合类型 T | U 表示一个值可以是类型 T 或类型 U。本质上是并集概念:

1
2
3
4
type A = string | number;
type B = 1 | 2 | 3 | 4;
type C = 'a' | 'b';
type D = string | number | boolean;

重要规则

  • 联合类型中的重复类型会被自动合并:string | stringstring
  • never 是联合类型的零元:T | neverT
  • any 与任何类型联合都是 anystring | anyany

2.2 交叉类型(Intersection Types)

交叉类型 T & U 表示一个值同时满足类型 T 和类型 U。本质上是交集概念:

1
2
3
4
5
type A = { name: string } & { age: number };
// { name: string; age: number }

type B = string & number; 
// never(没有值同时是 string 和 number)

重要规则

  • 交叉类型合并对象类型的属性:{a: number} & {b: string}{a: number; b: string}
  • 如果两个类型的同一属性有冲突,结果为 nevernever 相关的类型:{a: number} & {a: string}{a: never}
  • 基础类型之间的交叉通常是 never

2.3 联合与交叉的代数关系

1
2
A | B  ∼  "A or B"  ∼  并集
A & B  ∼  "A and B" ∼  交集
定律表达式
交换律A | B = B | AA & B = B & A
结合律(A | B) | C = A | (B | C)
分配律A & (B | C) = (A & B) | (A & C)
吸收律A & (A | B) = AA | (A & B) = A

2.4 分布式条件类型

分布式条件类型(Distributive Conditional Types)是联合类型操作的核心机制:

1
2
3
4
5
// 当 T 是联合类型时,条件类型会分布执行
type Distribute<T> = T extends U ? X : Y;

// T = A | B | C 时展开为:
// (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

三、最小示例:三种联合类型操作手写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ========== 1. UnionToIntersection<U> ==========
// 将联合类型转换为交叉类型
// 核心技巧:利用函数参数逆变

type UnionToIntersection<U> = 
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void
    ? I
    : never;

// ========== 2. UnionToTuple<T> ==========
// 将联合类型转换为元组类型
// (注意:联合类型是无序的,元组顺序不可控)

type UnionToTuple<T> = 
  UnionToIntersection<T extends any ? () => T : never> extends () => infer R
    ? [...UnionToTuple<Exclude<T, R>>, R]
    : [];

// ========== 3. IsUnion<T> ==========
// 判断一个类型是否是联合类型

type IsUnion<T, U extends T = T> = 
  (T extends any ? (U extends T ? false : true) : never) extends false
    ? false
    : true;

// 简化版(等价但更易理解的版本)
type IsUnion2<T, C extends T = T> = 
  [T] extends [never] 
    ? false
    : T extends C 
      ? [C] extends [T] ? false : true
      : never;

// ========== 验证 ==========

// UnionToIntersection 验证
type Test1 = UnionToIntersection<{ a: number } | { b: string }>;
// { a: number } & { b: string }

type Test2 = UnionToIntersection<string | number>;
// string & number = never(string 和 number 没有交集)

type Test3 = UnionToIntersection<(() => string) | (() => number)>;
// (() => string) & (() => number) — 函数类型的交叉

// UnionToTuple 验证
type Test4 = UnionToTuple<'a' | 'b' | 'c'>;
// ['a', 'b', 'c'] 或 ['a', 'c', 'b'](顺序不确定)

type Test5 = UnionToTuple<1 | 2 | 3>;
// [1, 2, 3] 或类似顺序

// IsUnion 验证
type Test6 = IsUnion<string | number>;    // true
type Test7 = IsUnion<string>;             // false
type Test8 = IsUnion<never>;              // false(never 不是联合)
type Test9 = IsUnion<1 | 2 | 3>;          // true
type Test10 = IsUnion<{ a: number } | { b: string }>; // true

// ========== 测试函数 ==========
function testUnionOperations() {
  // IsUnion 在条件类型中的应用
  type AssertNotUnion<T> = [IsUnion<T>] extends [false] ? T : never;
  
  function acceptSingle<T>(value: AssertNotUnion<T>): T {
    return value;
  }
  
  acceptSingle('hello'); // ✅ 单个类型可以
  acceptSingle(42);      // ✅
  // acceptSingle(Math.random() > 0.5 ? 'hello' : 42); // ❌ Error: string | number 被 AssertNotUnion 转为 never
  
  console.log('All type checks passed!');
}

四、核心知识点拆解

4.1 UnionToIntersection 的原理:逆变推断

UnionToIntersection 是最经典的联合操作类型,理解它对深入理解 TypeScript 类型系统至关重要。

逐步拆解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type UnionToIntersection<U> = 
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void
    ? I
    : never;

// 以 U = {a: number} | {b: string} 为例

// 步骤 1: 分布式条件类型展开
// (U extends any ? (k: U) => void : never)
// = ({a: number} extends any ? ...) | ({b: string} extends any ? ...)
// = ((k: {a: number}) => void) | ((k: {b: string}) => void)
// 结果:一个函数类型的联合

// 步骤 2: 将函数联合匹配到 (k: infer I) => void
// 由于函数参数是逆变的,多个函数参数类型的"并集"被推断为"交集"
// ((k: {a: number}) => void) | ((k: {b: string}) => void)
// extends (k: infer I) => void
// → I = {a: number} & {b: string}

逆变(Contravariance)解释

1
2
3
4
5
6
7
8
9
10
// 在类型系统中,函数参数是逆变的:
type Func<T> = (arg: T) => void;

// 如果 T1 extends T2,那么 Func<T2> extends Func<T1>
// 即参数类型的子类型关系在函数位置会翻转

// 所以:
// ((k: A) => void) | ((k: B) => void) matches (k: infer I) => void
// 时,I 必须同时兼容 A 和 B
// → I = A & B

4.2 UnionToTuple 的难点

将联合类型转为元组之所以难,是因为联合类型天生无序

1
2
3
type Order1 = 'a' | 'b' | 'c';
type Order2 = 'b' | 'a' | 'c';
// 在 TypeScript 中,Order1 和 Order2 是同一类型!

详细拆解

1
2
3
4
5
6
7
8
9
10
11
12
13
type UnionToTuple<T> = 
  UnionToIntersection<T extends any ? () => T : never> extends () => infer R
    ? [...UnionToTuple<Exclude<T, R>>, R]
    : [];

// 步骤分解(以 T = 'a' | 'b' 为例):
// 1. (T extends any ? () => T : never) → (() => 'a') | (() => 'b')
// 2. UnionToIntersection<(() => 'a') | (() => 'b')> → (() => 'a') & (() => 'b')
//    = overload(() => 'a', () => 'b') — 函数类型的交叉
// 3. 匹配 extends () => infer R → R = 'a'(最后一个重载的返回值)
// 4. Exclude<'a' | 'b', 'a'> → 'b'
// 5. 递归:UnionToTuple<'b'> → ['b']
// 6. 结果:['b', 'a']

注意UnionToTuple 的结果顺序受 TypeScript 重载解析策略影响,不是稳定的 FIFO/LIFO。在 TypeScript 不同版本中结果顺序可能不同。

4.3 IsUnion 的实现思路

IsUnion<T> 的核心是判断 T 是否包含多个成员:

1
2
3
4
5
6
type IsUnion<T, C extends T = T> = 
  [T] extends [never] 
    ? false
    : T extends C 
      ? [C] extends [T] ? false : true
      : never;

关键思路

T extends C(且 C = T)时,由于分布式条件类型,T 会被拆解为每个单独的成员,而 C 保持为完整的联合类型。如果 T 是单个类型(如 string),则 T extends CT = stringC = string[C] extends [T] 为 true → false。如果 T 是联合类型(如 string | number),则分布为:

  • string extends (string | number)[string | number] extends [string]?false → true
  • number extends (string | number)[string | number] extends [number]?false → true
  • 结果:true | truetrue

关键点是:通过 C extends T 缓存了完整的联合类型,然后在分布式的每个分支中比较 [C] extends [T](此时 T 是单个成员),如果 C 包含不止一个成员,则 [C] extends [T] 为 false。

4.4 联合类型在泛型中的特殊行为

联合类型在泛型中的行为有时出人意料:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 联合类型在泛型函数中的推导
function identity<T>(value: T): T { return value; }
const result = identity(Math.random() > 0.5 ? 'hello' : 42);
// result 类型为 string | number(T 被推断为联合)

// 2. 联合类型在泛型约束中的行为
function logValue<T extends string | number>(value: T): T {
  console.log(value);
  return value;
}
// T 可以被推断为 string、number 或 string | number

// 3. 联合类型与泛型类
class Container<T> {
  constructor(public value: T) {}
}
const c = new Container(Math.random() > 0.5 ? 'hello' : 42);
// Container<string | number>

五、实战案例:类型安全的事件系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// ========== 场景:构建类型安全的事件发布/订阅系统 ==========

// 事件定义
interface EventMap {
  'user:login': { userId: number; timestamp: number };
  'user:logout': { userId: number; timestamp: number };
  'user:update': { userId: number; changes: Partial<{ name: string; email: string }> };
  'message:new': { messageId: number; content: string; from: number; to: number };
  'message:delete': { messageId: number; deletedBy: number };
  'system:error': { code: number; message: string; fatal: boolean };
  'system:warning': { code: number; message: string };
}

type EventNames = keyof EventMap;
// 'user:login' | 'user:logout' | 'user:update' | 'message:new' | 'message:delete' | 'system:error' | 'system:warning'

// ---------- 1. 使用 IsUnion 约束泛型参数 ----------

// 防止同时监听多个事件——每个 listener 只监听一个事件
function on<E extends EventNames>(
  event: E,
  handler: (payload: EventMap[E]) => void,
  options?: { once?: boolean }
): void {
  // 实现...
  console.log(`Registered handler for ${event}`);
}

// ✅ 正确使用
on('user:login', (payload) => {
  // payload 类型为 { userId: number; timestamp: number }
  console.log(payload.userId);
});

// ❌ 如果试图传联合类型,类型系统阻止
// function on<E extends EventNames>(event: E, handler: (payload: EventMap[E]) => void)
// 这里 E 本身可以是联合,但每个 handler 的 payload 类型会变为 EventMap[E] = EventMap[E1] | EventMap[E2]

// ---------- 2. 展开事件名称联合为元组 ----------

// 工具类型:从联合类型生成元组(用于枚举)
// 注意:实际应用中,union 的元组顺序不可控,主要用于条件类型

type EventNamesArray = UnionToTuple<EventNames>;
// ['system:warning', 'system:error', 'message:delete', 'message:new', 'user:update', 'user:logout', 'user:login']

// ---------- 3. 联合转交叉——组合事件类型 ----------

// 将多个事件的 payload 组合为一个类型
type CombinedPayload<E extends EventNames> = 
  UnionToIntersection<EventMap[E]>;

// 例如监听 'user:login' | 'message:new'
type LoginAndMessage = CombinedPayload<'user:login' | 'message:new'>;
// { userId: number; timestamp: number } & { messageId: number; content: string; from: number; to: number }
// → { userId: number; timestamp: number; messageId: number; content: string; from: number; to: number }

// ---------- 4. 条件事件绑定 ----------

// 定义事件组
type UserEvents = 'user:login' | 'user:logout' | 'user:update';
type MessageEvents = 'message:new' | 'message:delete';
type SystemEvents = 'system:error' | 'system:warning';
type AllEvents = UserEvents | MessageEvents | SystemEvents;

// 判断事件是否属于某个分类
type IsUserEvent<E> = E extends UserEvents ? true : false;
type IsMessageEvent<E> = E extends MessageEvents ? true : false;

// 按分类绑定
function onUserEvent<E extends UserEvents>(
  event: E,
  handler: (payload: EventMap[E]) => void
): void {
  on(event, handler);
}

// 批量绑定
function onAllEventsIn<T extends AllEvents>(
  events: T[],
  handler: (name: T, payload: EventMap[T]) => void
): void {
  events.forEach(event => {
    on(event, (payload) => handler(event, payload as any));
  });
}

// ---------- 5. 使用示例 ----------
onAllEventsIn(['user:login', 'user:logout'], (name, payload) => {
  // name: 'user:login' | 'user:logout'
  // payload: { userId: number; timestamp: number }
  console.log(`Event ${name} from user ${payload.userId}`);
});

// ---------- 6. 运行时联合类型验证 ----------

// 判断值是否是联合联合中的一员(运行时)
function isEventOfType<E extends AllEvents>(
  eventName: string,
  eventSet: UnionToTuple<E>
): eventName is E {
  return (eventSet as string[]).includes(eventName);
}

// 使用
function handleSystemEvent(eventName: string, payload: any) {
  // 判断是否是系统事件
  if (eventName === 'system:error' || eventName === 'system:warning') {
    // 窄化后 payload 为 SystemEvents 对应的类型
    processSystemEvent(eventName as SystemEvents, payload);
  }
}

六、底层原理

6.1 联合类型在编译器中的表示

TypeScript 编译器中,联合类型在内部分为两种表示:

已计算联合(Computed Union)

1
2
3
// 简单的字面量联合
type Status = 'idle' | 'loading' | 'success';
// 编译器内部表示:UnionType(types: ['idle', 'loading', 'success'])

未计算联合(Uncomputed Union)

1
2
3
// 通过条件类型产生的联合
type Result<T> = T extends string ? 'string' : 'not-string';
// 编译器内部表示:DeferredConditionalType,不立即展开

处理分布式条件类型时,编译器会:

  1. 检查条件类型是否适用于联合类型参数(裸参数且 extends any 或 extends 一个非泛型类型)
  2. 如果是,将联合类型拆解为每个成员
  3. 对每个成员应用条件
  4. 将结果重新合并为新的联合类型

6.3 联合类型的分配律与逆变

联合类型和函数类型之间存在一个有趣的关系:

1
((A | B) → C) 等价于 (A → C) & (B → C)

这决定了为什么 UnionToIntersection 中的逆变 trick 能工作:

1
2
3
4
5
// ((k: A) => void) | ((k: B) => void) 可以匹配 (k: infer I) => void
// 等价于说:
// 有一个函数 f,当参数是 A 时做某事,当参数是 B 时做某事
// 那么 f 必须能同时处理 A 和 B
// → f 的参数类型必须是 A & B

6.4 联合类型的类型兼容性规则

1
2
3
4
5
6
7
8
9
10
11
// 规则 1: 如果 T extends U 成立,那么 T | V extends U | V 也成立
type R1 = 'a' extends string ? true : false; // true
type R2 = ('a' | 1) extends (string | number) ? true : false; // true

// 规则 2: 联合类型到交叉类型是兼容的
type R3 = { a: number } extends { a: number } & { b: string } ? true : false; // false
type R4 = { a: number; b: string } extends { a: number } | { b: string } ? true : false; // true

// 规则 3: never 是类型系统的底类型(bottom type)
type R5 = never extends string ? true : false; // true
type R6 = never extends 'a' | 'b' ? true : false; // true

七、高频面试题解析

面试题 1:解释 UnionToIntersection<U> 的工作原理,为什么能实现”联合转交叉”?

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 核心原理:函数参数的逆变(Contravariance)

type UnionToIntersection<U> = 
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void
    ? I
    : never;

// 步骤 1:分布式条件类型将 U 的每个成员转为函数类型
// U = A | B | C
// 展开为:(k: A) => void | (k: B) => void | (k: C) => void

// 步骤 2:函数类型的联合匹配到单个函数类型 (k: infer I) => void
// 这里利用了 TypeScript 的一个特性:
// 函数类型 (k: A) => void 可以赋值给 (k: infer I) => void,其中 I 被推断为 A
// 但当多个函数的联合匹配时,I 必须是所有参数类型的"交集"

// 这是因为:
// ((k: A) => void) | ((k: B) => void)
// 它的"能力"是能处理 A 或能处理 B
// 但当我们要求它是特定的 (k: infer I) => void 时
// 必须同时能处理 A 和 B
// → I 必须是 A 与 B 的交集

// 一个帮助理解的类比:
// 有一个机器人,它要么会修车(A:Car),要么会修船(B:Boat)
// 要称它为"专业维修工",它必须同时会修车和修船
// → 它需要的技能交集中既包含 Car 也包含 Boat

实际应用场景

1
2
3
4
5
6
7
8
// 当你需要组合多个配置时:
interface LoggingConfig { logLevel: string; logPath: string; }
interface DatabaseConfig { dbHost: string; dbPort: number; }
interface CacheConfig { cacheTTL: number; cacheProvider: string; }

type CombinedConfig = UnionToIntersection<LoggingConfig | DatabaseConfig | CacheConfig>;
// = LoggingConfig & DatabaseConfig & CacheConfig
// = { logLevel: string; logPath: string; dbHost: string; dbPort: number; cacheTTL: number; cacheProvider: string; }

面试题 2:实现 IsUnion<T> 并解释为什么 [T] extends [C] 的检查能判断是否为联合类型。

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type IsUnion<T, C extends T = T> = 
  [T] extends [never] ? false
  : T extends C ? [C] extends [T] ? false : true
  : never;

// 以 T = string | number 为例

// 第 1 步:[T] extends [never] → false(继续)
// 因为 [string | number] 不 extends [never]

// 第 2 步:T extends C (这里 T 是联合 string | number,C 也是 string | number)
// 由于分布式条件类型,这被展开为:
//   string extends string | number  → 执行条件体
//   number extends string | number  → 执行条件体

// 第 3 步(在第一个分支中):
// T = string(当前分布的成员),C = string | number(原始的完整联合)
// [C] extends [T] 就是 [string | number] extends [string]
// 这为 false!因为 string | number 不能赋值给 string
// 所以这个分支返回 true

// 第 4 步(在第二个分支中):
// T = number,C = string | number
// [C] extends [T] 就是 [string | number] extends [number]
// 也是 false!返回 true

// 第 5 步:结果 = true | true = true ✓

// 对比 T = string(非联合类型)
// 第 1 步:继续
// 第 2 步:string extends string → 执行条件体(只有一次,不分布)
// 第 3 步:T = string,C = string
// [C] extends [T] 就是 [string] extends [string]
// true!返回 false

// 结果 = false ✓

扩展版本——区分联合类型和交叉类型:

1
2
3
4
type TypeKind<T> = 
  IsUnion<T> extends true 
    ? T extends any ? 'union' : never
    : 'single';

面试题 3:为什么联合类型到元组的转换结果顺序不可预测?有什么替代方案?

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 联合类型的无序性
type ABC = 'a' | 'b' | 'c';
type CBA = 'c' | 'b' | 'a';
// ABC 和 CBA 是同一个类型!

// 因此,UnionToTuple 的结果顺序是未定义的
type Tuple1 = UnionToTuple<'a' | 'b' | 'c'>; // 可能 ['a','b','c'] 或 ['c','b','a']

// 替代方案 1:使用元组字面量代替联合(如果顺序重要)
type OrderedEvents = ['user:login', 'user:logout', 'user:update'];
// 用数组代替联合保留顺序
type EventNameFromTuple = OrderedEvents[number]; // 'user:login' | 'user:logout' | 'user:update'

// 替代方案 2:使用 const 对象(如果有关联数据)
const EventPriority = {
  'system:error': 0,
  'system:warning': 1,
  'user:login': 2,
  'user:logout': 3,
} as const;

type EventNameFromObject = keyof typeof EventPriority; // 联合类型
type EventPriorityValue = typeof EventPriority[EventNameFromObject]; // 0 | 1 | 2 | 3

// 替代方案 3:使用模板字符串保留顺序
type SequentialEvents = 'evt1' | 'evt2' | 'evt3';
// 如果需要顺序信息,编码到类型名中

// 替代方案 4:显式元组
const ALL_EVENTS = ['user:login', 'user:logout', 'user:update', 'message:new'] as const;
type AllEventsFromConst = typeof ALL_EVENTS[number];
// 'user:login' | 'user:logout' | 'user:update' | 'message:new'
// 而且 ALL_EVENTS 数组保留了顺序

八、总结与扩展

核心要点

  1. 联合类型操作的核心是分布式条件类型:所有联合操作都依赖于分布式条件类型的特性
  2. 逆变是实现联合转交叉的关键:利用函数参数的逆变特性,将”或”转换为”与”
  3. 联合转元组顺序不可控:联合类型天生无序,需要元组字面量或 const 对象来保留顺序
  4. IsUnion 的”锁”技巧:通过 [C] extends [T] 在分布式中保持完整的联合类型供比较

实际应用建议

需求推荐方案
需要组合多个类型显式使用 & 交叉类型,而非 UnionToIntersection
需要判断是否是联合使用 IsUnion(但很少在业务代码中需要)
需要保留顺序使用 as const 元组或 const 对象
需要从联合中逐个提取考虑递归条件类型 + Exclude
需要安全的联合操作优先使用 TypeScript 原生能力(Extract, Exclude 等)

扩展阅读

联合类型的操作是 TypeScript 类型体操中最复杂的部分之一。深入理解分布式条件类型和函数参数的协变/逆变,是掌握高级类型编程的关键。

本文由作者按照 CC BY 4.0 进行授权