# 进阶-逆变顺变协变

协变、逆变、双变是 TS 类型系统里理解函数类型和赋值兼容性的核心。TS 体操里用到它的主要场景是 UnionToIntersection —— 这个几乎所有 hard 题都会引用的工具。本文先讲清楚协变 / 逆变的定义,再讲它为什么能用来实现联合转交叉。

# 协变 / 逆变 / 不变

用一句话概括:

  • 协变 (covariant):类型参数的方向"随子类型一起走"。比如 AB 的子类型,那么 Wrap<A> 也是 Wrap<B> 的子类型。
  • 逆变 (contravariant):类型参数的方向"和子类型相反"。比如 AB 的子类型,但 Wrap<A> 却是 Wrap<B>类型。
  • 双变 (bivariant):两个方向都兼容。
  • 不变 (invariant):只接受完全相同的类型。

举个直观的例子:

type Animal = { name: string };
type Dog = { name: string; bark(): void };

// Dog 是 Animal 的子类型:Dog 能赋给 Animal
const a: Animal = {} as Dog; // ✓

// 协变场景:数组
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // ✓,Dog[] 是 Animal[] 的子类型

// 逆变场景:函数参数
type Handler<T> = (x: T) => void;
const handleAnimal: Handler<Animal> = (x) => {};
const handleDog: Handler<Dog> = handleAnimal; // ✓
//      ^^^^^^^ Handler<Animal> 能赋给 Handler<Dog>
//              原因:能处理 Animal 的函数,也能处理更具体的 Dog

关键观察:一个函数的参数位置是逆变的。这正是 UnionToIntersection 的切入点。

# 函数参数的逆变推断

TS 有一条规则:当多个同名函数类型通过 infer 在参数位置汇合时,会被合并成"交集"(交叉类型)而不是"并集"。原因是:要想一个函数能同时满足多个"身份",它的参数必须能接收所有身份的并集 —— 也就是参数类型的交叉

type F = ((x: { a: 1 }) => void) & ((x: { b: 2 }) => void);
type X = F extends (x: infer P) => void ? P : never;
// X = { a: 1 } & { b: 2 }

记住这张图:

  • 协变位置 + infer → 得到联合;
  • 逆变位置 + infer → 得到交叉。

# UnionToIntersection 的完整推导

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

分两步看:

  1. U extends any ? (x: U) => any : never:这一步利用分发,把联合 U = A | B | C 分别映射成 (x: A) => any | (x: B) => any | (x: C) => any,再合并成一个联合的函数类型。
  2. extends (x: infer I) => anyU 现在是函数联合,要让 I 同时能接住这三种参数,TS 就在逆变位置上把它们合并成交叉:I = A & B & C

验证:

type R = UnionToIntersection<{ a: 1 } | { b: 2 }>;
// { a: 1 } & { b: 2 }

如果只是想把对象合并:

type Merge<T> = { [K in keyof T]: T[K] };
type R2 = Merge<UnionToIntersection<{ a: 1 } | { b: 2 }>>;
// { a: 1; b: 2 }

这是 hard/55 UnionToIntersection 的完整题解,也是几乎所有涉及"联合 → 对象合并""提取联合公共字段""联合转元组末项"等 hard 技巧的基础。

# 逆变 + infer 的其它用法

# 取联合最后一项

type LastOfUnion<U> = UnionToIntersection<
  U extends any ? (x: U) => any : never
> extends (x: infer L) => any
  ? L
  : never;

type R = LastOfUnion<'a' | 'b' | 'c'>; // 'c'

原理:多个同名函数类型被交叉后,TS 在参数位置上不是真正取交集,而是按最后声明的那个为准(重载决议规则)。所以能抽出联合的"最后一支"。

# 联合转元组

基于 LastOfUnion,可以递归地把联合拆成元组:

type UnionToTuple<U, R extends any[] = []> = [U] extends [never]
  ? R
  : UnionToTuple<Exclude<U, LastOfUnion<U>>, [LastOfUnion<U>, ...R]>;

细节见 hard/730-联合转元组

# 协变 + infer

协变场景相对直白,常见于元组 / 数组 / 对象值位置的 infer:

type ValueOf<T> = T extends { [K: string]: infer V } ? V : never;
type R = ValueOf<{ a: 1; b: 2 }>; // 1 | 2  — 联合

协变位置拿到的是联合,符合直觉。

# strictFunctionTypes 的影响

注意,TS 默认会让非箭头函数的参数双变的:

type F = (x: Animal) => void;
const f: F = (x: Dog) => {}; // 默认允许(不严格)

只有 strictFunctionTypes: truestrict: true 会开启)+ 函数用 (x) => ... 箭头形式时,才严格按逆变规则走。TS 体操题通常跑在严格模式下,所以逆变推断能成立。

# 陷阱

# 方法参数是双变

type O = { handle(x: Animal): void }; // 方法,默认双变
type O2 = { handle: (x: Animal) => void }; // 函数字段,严格模式下逆变

写 UnionToIntersection 时,用箭头函数语法 (x: U) => any 最稳。

# infer 在协变位置得联合

这是和逆变完全对称的点:

// 把函数返回值收集成联合
type Returns<T> = T extends (...args: any) => infer R ? R : never;
type R = Returns<(() => 1) | (() => 2)>; // 1 | 2

返回值是协变位置,得到的是联合;参数位置是逆变,得到交叉。一眼能分辨这两种 infer 的输出,就算真的掌握了。

# 被谁用到

记住核心规律:逆变位置的 infer 得到交叉,协变位置的 infer 得到联合。碰到"需要把联合合并成一个整体对象"的题,UnionToIntersection 就是答案。

Last Updated: 2026/4/19 00:26:40