协变与逆变的概念深度解析
协变与逆变是函数类型兼容性的底层法则:返回值位置协变(可更具体),参数位置逆变(可更宽泛)。 面试常考 strictFunctionTypes 开启后参数为何逆变、为什么能把 Handler Animal 赋给 Handler Dog,理解子类型关系即可答对。
一句话概括
协变 = 子类型方向”顺着”父→子,逆变 = 子类型方向”反着”来——函数参数逆变(可以接受更宽的类型),返回值协变(可以返回更具体的类型)。这两条是函数类型兼容性的底层法则,不理解就只能靠背”这个T是协变还是逆变”来应付面试。
核心知识点
1. 先建立直觉:子类型可以当父类型用
1
2
3
4
5
6
class Animal { name = ""; }
class Dog extends Animal { breed = ""; }
class Cat extends Animal { color = ""; }
const pet: Animal = new Dog(); // ✅ Dog 是 Animal 的子类型
// 子类型拥有父类型的所有属性,所以放进"需要父类型的位置"是安全的
2. 协变:返回值可以”更具体”
1
2
3
4
5
6
7
8
9
10
11
12
type Factory<T> = () => T;
// Dog extends Animal,所以 Factory<Dog> 可以赋给 Factory<Animal>
const dogFactory: Factory<Dog> = () => new Dog();
const animalFactory: Factory<Animal> = dogFactory; // ✅ 协变
// 语义:说好了"给我一个 Animal",结果给的是 Dog
// → Dog 有 Animal 的所有属性和方法 → 安全
// ❌ 反过来不行:
// const badFactory: Factory<Dog> = () => new Animal();
// 说好了给我 Dog,结果给的是 Animal → 没有 breed 属性 → 炸
规则:输出位置(返回值)= 协变。类型参数的子类型关系方向与类型实例相同。
3. 逆变:参数可以”更宽泛”
1
2
3
4
5
6
7
8
9
10
11
12
type Handler<T> = (x: T) => void;
// 开了 strictFunctionTypes 后:参数位置是逆变的
const animalHandler: Handler<Animal> = (a: Animal) => console.log(a.name);
const dogHandler: Handler<Dog> = animalHandler; // ✅ 逆变
// 语义:说好了"传我一个 Dog",实际上给的处理函数能处理任何 Animal
// → Dog 也是 Animal → 安全
// ❌ 反过来不行:
// const badHandler: Handler<Animal> = (d: Dog) => d.breed;
// 说好了处理任意 Animal,结果函数只认 Dog → 传入 Cat 时 .breed 不存在 → 炸
规则:输入位置(函数参数)= 逆变。类型参数的子类型关系方向与类型实例相反。
4. 不变:读写双用 = 必须完全匹配
1
2
3
4
5
6
7
8
interface Box<T> {
value: T; // 既能读(协变要求)又能写(逆变要求)→ 必须不变
}
const dogBox: Box<Dog> = { value: new Dog() };
// const animalBox: Box<Animal> = dogBox; // ❌ 不安全
// 如果这行允许:animalBox.value = new Cat();
// 那 dogBox.value 实际上是 Cat! 类型系统被绕过了
规则:既输入又输出的位置 = 不变。类型参数不能有任何子类型关系。
5. TypeScript 的实际行为——双变”陷阱”
1
2
3
4
5
6
7
8
9
10
// 方法声明(method shorthand)→ 永远是双变(bivariant),非安全设计
interface Comparator<T> {
compare(a: T, b: T): number; // 双变
}
// 函数类型声明(arrow or function type)→ strict 下参数逆变
type CompareFn<T> = (a: T, b: T) => number; // strictFunctionTypes 下逆变
// 结论:同一个参数类型,声明方式不同,变型行为不同!
// 这是 TS 为了兼容 JS 生态做的妥协
其实你每天都在用
- 数组是协变的(不安全):
const pets: Animal[] = [new Dog(), new Cat()]✅ — 但pets.push(new Cat())不报错(运行时 Dog[] 上 push Cat 不会炸,但逻辑上不对劲) - ReadonlyArray 是安全的协变:只读保证不会有 push 绕过类型系统
- 事件处理器的逆变:
addEventListener("click", handler: (e: Event) => void)接受(e: MouseEvent) => void— 参数逆变保证 handler 能处理更具体的子事件 Promise<Dog>可以赋给Promise<Animal>:Promise 的 then 回调返回值是协变的- Redux reducer:
(state: State, action: Action)— action 参数逆变,reducer 能接受子类型 Action
常见误解(FAQ)
❌ 误区 1:「strictFunctionTypes 开了就万事大吉,所有函数参数都逆变」
只对函数类型声明生效(type Fn = (x: T) => void)。方法声明(interface I { fn(x: T): void })的参数永远是双变,strictFunctionTypes 管不到。这是 TS 设计上为了兼容 Array<T>.forEach 等内置方法的历史妥协。
❌ 误区 2:「Dog[] extends Animal[],所以赋值是安全的」
TypeScript 默认数组协变,但不安全。const arr: Animal[] = [new Dog()]; arr.push(new Cat()) 不报类型错,逻辑上把猫塞进狗数组。要安全用 ReadonlyArray<T>。
❌ 误区 3:「协变和逆变太学术了,写业务代码用不到」
你写的每一个事件处理函数、每一个 useReducer、每一个 Promise.then 都在依赖协变/逆变规则。不理解它,遇到 Type 'X' is not assignable to type 'Y' 的错误就只能瞎试,改不对就知道疼了。
❌ 误区 4:「返回值既是输出,(x: Dog) => Dog 的参数和返回值各是什么变型」
参数是逆变的((x: Animal) => Dog 可以赋给 (x: Dog) => void),返回值是协变的(() => Dog 可以赋给 () => Animal)。一个函数类型可以同时有逆变位置和协变位置,面试这种”混合函数类型”的兼容性判断是高频考题。
一句话总结
“我接受更宽,我返回更具体”——参数逆变保证你给我的输入我能接住,返回值协变保证我给你的输出你能用。九个字记住:参数逆、返回协、读写不变。