文章

联合类型操作深度解析

联合类型 A 竖线 B 是或,交叉类型 A 和 B 是且,但要驾驭它们得理解分布式条件类型怎么拆联合、函数参数逆变怎么把联合变交叉。 IsUnion、UnionToTuple、UnionToIntersection 等经典体操背后是类型运算思维,面试能写出即证明真正理解类型系统。

联合类型操作深度解析

一句话概括

联合类型(A | B)是”或”,交叉类型(A & B)是”且”——但要真正驾驭它们,你需要理解分布式条件类型怎么拆解联合、函数参数逆变怎么把联合变成交叉、以及 IsUnion / UnionToTuple 这些经典体操背后的类型运算思维。

核心知识点

1. 分布式条件类型:自动拆解联合的”语法糖”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 当泛型 T 是裸类型参数时,T extends U 会把联合拆开逐条判断
type Distribute<T> = T extends string ? `${T}-str` : T;

type A = Distribute<'a' | 1>; // 'a-str' | 1
// 等价于:('a' extends string ? 'a-str' : 'a') | (1 extends string ? '1-str' : 1)

// ⚠️ 停止分布式:用 [] 或 {} 包裹
type NoDist<T> = [T] extends [string] ? 'yes' : 'no';
type B = NoDist<'x' | 'y'>; // 'no' ← 整体匹配,不拆开

// Exclude 和 Extract 就是这么实现的
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type C = MyExclude<'a' | 'b' | 'c', 'b'>; // 'a' | 'c'

2. 联合转交叉:UnionToIntersection

这是面试经典”反转”操作,用函数参数逆变特性实现:

1
2
3
4
5
// 核心理念:((x: A) => void) | ((x: B) => void) 匹配 (x: infer I) => void 时,I = A & B
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;

type X = UnionToIntersection<{ a: true } | { b: false }>; // { a: true } & { b: false }

分步拆解:

  1. 分布式将 A|B 变为 ((k:A)=>void) | ((k:B)=>void)
  2. 整个联合去匹配 (k: infer I) => void,因为函数参数是逆变的,TS 推断 I = A & B

3. 判断是否联合:IsUnion

1
2
3
4
5
6
7
8
9
10
type IsUnion<T, U = T> =
  [T] extends [never]        // never 特判
    ? false
    : T extends U             // 分布式检查
      ? [U] extends [T] ? false : true  // 每个成员和自己比
      : never;

type Test1 = IsUnion<string>;          // false
type Test2 = IsUnion<string | number>; // true
type Test3 = IsUnion<never>;           // false

原理:T extends U 分布式执行。U = T(原始类型)。对于联合 'a'|'b','a' extends 'a'|'b' → 检查 ['a'|'b'] extends ['a'] → false → 返回 true(有一个分支不是自己)。对于单类型 'a','a' extends 'a' → 检查 ['a'] extends ['a'] → true → 返回 false。

4. 联合转元组:UnionToTuple

这个操作非常”反直觉”——联合类型是无序的,但元组是有序的。需要用函数重载的逆变技巧:

1
2
3
4
5
6
7
8
9
// 精简版——利用函数返回类型的取交集顺序
type LastOf<T> =
  UnionToIntersection<T extends any ? () => T : never> extends () => infer R ? R : never;

// 递归弹出最后一个元素
type UnionToTuple<T, L = LastOf<T>> =
  [T] extends [never] ? [] : [...UnionToTuple<Exclude<T, L>>, L];

type T = UnionToTuple<3 | 1 | 2>; // [1, 2, 3] ← 顺序不保证,每次编译可能不同!

重要警告:联合类型的顺序在 TS 内部是不确定的,UnionToTuple 的结果顺序不可依赖。这只是一种类型体操练习,生产代码中不推荐依赖它。

5. K in T 和 T[K] 的联合类型行为

1
2
3
4
5
6
7
8
// 索引访问返回联合类型
type Obj = { a: string; b: number; c: boolean };
type Values = Obj[keyof Obj];          // string | number | boolean
type Keys = keyof Obj;                 // 'a' | 'b' | 'c'

// 映射类型的遍历
type ToArray<T> = { [K in keyof T]: T[K][] };
type Arr = ToArray<Obj>; // { a: string[]; b: number[]; c: boolean[] }

其实你每天都在用

  1. Redux action 的联合类型——type Action = { type: 'INCREMENT' } | { type: 'DECREMENT'; amount: number },reducer 中的 switch(action.type) 配合 action 的 discriminated union,TypeScript 自动在 case 分支中窄化类型。这是 TypeScript 最常用的联合类型模式之一。
  2. React 组件的 children 类型——React.ReactNode 就是一个巨大的联合类型:string | number | boolean | ReactElement | ReactPortal | null | undefined | ...。它囊括了所有可以放在 JSX 标签之间的东西。
  3. Object.keys 的类型陷阱——Object.keys({ a: 1, b: 2 }) 返回 string[] 而非 ('a'|'b')[](这是个谨慎的设计选择),但你可以用 (keyof typeof obj)[] 做更精确的类型标注,这背后就是 keyof(联合类型)。
  4. CSS 属性的联合类型——type TextAlign = 'left' | 'right' | 'center' | 'justify',用联合类型约束合法的 CSS 值,IDE 自动补全 + 编译时报错,比 string 类型安全一个量级。
  5. API 状态的 discriminated union——type State = { status: 'loading' } | { status: 'success'; data: Data } | { status: 'error'; error: Error },这比用 status: string + data? 可选字段的模式少写 90% 的类型守卫代码。

常见误解(FAQ)

  • ❌ 误区:「A & B 就是同时具有 A 和 B 的所有属性」 真相:对两个对象类型确实是。但当 A 和 B 有同名属性的不同类型时,A & B 的属性类型是两者的交叉。{ x: string } & { x: number } 的 x 类型是 string & number(即 never,不可能同时满足),这个对象几乎没法用。

  • ❌ 误区:「联合类型和元组可以无损互转」 真相:不能。联合类型是无序集合('a'|'b' 等于 'b'|'a'),元组是有序序列。即便用 UnionToTuple 转出了顺序,那个顺序是 TS 内部实现决定的,随版本可能变化,不可依赖。

  • ❌ 误区:「T extends U 如果 T 和 U 都在内建类型系统里,结果一定可控」 真相:any extends string 的结果是 boolean(既是 true 也是 false),因为 any 在条件类型中是”薛定谔的猫”。never extends string 永远走空(分布式短路)。这两个边界情况经常出 bug。

  • ❌ 误区:「分布式条件类型会发生在所有泛型参数上」 真相:只有当泛型参数直接出现在 extends 左边(裸类型参数)时才分布式。[T] extends [U] 或 { x: T } extends { x: U } 都不会触发。这也是 IsNever<T> 需要用 [T] extends [never] 的关键原因——T extends never 在 T=never 时分布式走了空。

一句话总结

联合类型和交叉类型不只是简单的”或”与”且”——它们是 TypeScript 类型计算的基础单元:分布式条件类型用联合做隐式的 map,函数参数逆变让联合变成交叉,keyof 和索引访问让对象和联合互相转换——理解这三层,你就理解了 TypeScript 类型系统的运算内核。

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