文章

协变与逆变的概念

协变与逆变的概念

一句话概括

协变 = 子类型关系顺着传,逆变 = 子类型关系反着传——返回值协变(可以返回更具体的)、参数逆变(可以接受更宽泛的)。搞混了这两条,函数类型兼容性永远靠猜。

核心知识点

1. 先建立直觉:子类型可以塞给父类型

1
2
3
4
class Animal { name = ""; }
class Dog extends Animal { breed = ""; }

const a: Animal = new Dog(); // ✅ Dog 可以当 Animal 用

2. 协变:返回值可以”更具体”

1
2
3
4
5
6
7
8
type Factory<T> = () => T;

const dogFactory: Factory<Dog> = () => new Dog();
const animalFactory: Factory<Animal> = dogFactory; // ✅
// 说好了返回 Animal,实际返回 Dog → 安全(Dog 拥有 Animal 所有属性)

// 反过来不行:
// const wrong: Factory<Dog> = () => new Animal(); // ❌ 说返回 Dog,实际返回 Animal

规则:输出位置(返回值)= 协变。子类型方向相同。

3. 逆变:参数可以”更宽泛”

1
2
3
4
5
6
7
8
type Handler<T> = (x: T) => void;

const animalHandler: Handler<Animal> = (a: Animal) => console.log(a.name);
const dogHandler: Handler<Dog> = animalHandler; // ✅(strictFunctionTypes 开启时)
// 说好了传 Dog,实际函数能处理 Animal → 安全(Dog 也是 Animal)

// 反过来不行:
// const wrong: Handler<Animal> = (d: Dog) => d.bark(); // ❌ 说处理 Animal,实际只能处理 Dog

规则:输入位置(参数)= 逆变。子类型方向相反。

4. 不变:同时输入又输出 = 必须完全匹配

1
2
3
4
5
6
7
8
9
interface Box<T> {
  value: T;
}
// Box<Dog> 和 Box<Animal> 互不兼容
// 因为 value 既可读(协变要求)又可写(逆变要求)→ 必须不变

const db: Box<Dog> = { value: new Dog() };
// const ab: Box<Animal> = db; // ❌ 如果允许这行:
// ab.value = new Cat();       // db.value 现在是 Cat!类型炸了

5. TypeScript 的实际行为——双变陷阱

1
2
3
4
5
6
7
8
9
10
// 默认情况下(non-strict),函数参数是双变(bivariant)
let f1: (x: Dog) => void = (x: Animal) => {};   // ✅ 逆变安全
let f2: (x: Animal) => void = (x: Dog) => {};   // ✅ 双变允许(但实际上不安全!)
// 开启 strictFunctionTypes 后,f2 会报错

// 接口中的方法声明永远是双变(历史兼容),函数类型声明是逆变
interface Compare<T> {
  compare(a: T, b: T): number; // 方法 → 双变
}
type CompareFn<T> = (a: T, b: T) => number; // 函数类型 → strict 下逆变

「其实你每天都在用」

  1. 数组是协变的const animals: Animal[] = dogs ✅——代价是 animals.push(new Cat()) 不会报类型错(运行时会炸)
  2. 事件处理器onChange: (e: ChangeEvent) => void 接受 (e: Event) => void 的处理器——参数逆变,安全
  3. Promise 是协变的Promise<Dog> 可以赋给 Promise<Animal>
  4. Redux reducer 参数(state, action: Action) 可以接受子类型 action——逆变确保安全
  5. ReadonlyArray 是安全的协变:只读数组天然协变且安全,因为不能 push

常见误解(FAQ)

❌ 误区 1:TypeScript 严格模式下所有函数参数都逆变

接口/类中声明的方法参数永远是双变(bivariant),只有函数类型声明(type Fn = (x: T) => …)的参数在 strictFunctionTypes 下才逆变。这是为了兼容 JS 生态的故意设计。

❌ 误区 2:Dog[] extends Animal[] 所以可以安全赋值

TypeScript 默认数组是协变的,但不安全const animals: Animal[] = [new Dog()]; animals.push(new Cat()) 不会报类型错。要安全用 ReadonlyArray<T>

❌ 误区 3:逆变太反直觉,跟我写业务没关系

写 Redux/zustand 的 selector、React 的事件处理、Vue 的 emit 类型推导——底层全是协变/逆变。不理解它们,遇到 Type 'X' is not assignable to type 'Y' 就只能瞎试。

❌ 误区 4:strictFunctionTypes 开了就行,不用理解原理

开了 strictFunctionTypes 只对函数类型声明生效,方法声明仍然双变。遇到方法声明参数的类型兼容性报错不报错,不搞清楚规则就想不通。

一句话总结

“你给我什么,我按更宽的取;我要你给我什么,你得给更细的”——记住这句话,协变和逆变就不乱了。

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