# 进阶-逆变顺变协变
协变、逆变、双变是 TS 类型系统里理解函数类型和赋值兼容性的核心。TS 体操里用到它的主要场景是 UnionToIntersection —— 这个几乎所有 hard 题都会引用的工具。本文先讲清楚协变 / 逆变的定义,再讲它为什么能用来实现联合转交叉。
# 协变 / 逆变 / 不变
用一句话概括:
- 协变 (covariant):类型参数的方向"随子类型一起走"。比如
A是B的子类型,那么Wrap<A>也是Wrap<B>的子类型。 - 逆变 (contravariant):类型参数的方向"和子类型相反"。比如
A是B的子类型,但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;
分两步看:
U extends any ? (x: U) => any : never:这一步利用分发,把联合U = A | B | C分别映射成(x: A) => any | (x: B) => any | (x: C) => any,再合并成一个联合的函数类型。extends (x: infer I) => any:U现在是函数联合,要让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: true(strict: 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 的输出,就算真的掌握了。
# 被谁用到
- UnionToIntersection:hard/55-UnionToIntersection。
- 联合转元组:hard/730-联合转元组, hard/472-元组转 EnumObject。
- 函数返回值 infer:medium/2-获取函数返回类型。
- 参数 infer + 逆变:medium/3312-实现 Parameters。
- 需要合并联合中对象字段的 hard 题:hard/9160-Assign。
记住核心规律:逆变位置的 infer 得到交叉,协变位置的 infer 得到联合。碰到"需要把联合合并成一个整体对象"的题,UnionToIntersection 就是答案。