TypeScript 中协变、逆变和双变是什么意思?
简化版
协变表示类型关系方向保持一致,常见于只读返回值;逆变表示类型关系方向反过来,常见于函数参数;双变表示两个方向都允许,是 TypeScript 为兼容部分回调场景保留的宽松行为。
理解它的关键是函数类型是否安全:返回更具体的值通常安全,参数随便变窄可能不安全。开启 strictFunctionTypes 后,普通函数参数检查会更严格。
详细版
type Animal = { name: string };
type Dog = Animal & { bark(): void };
let dogs: Dog[] = [];
let animals: Animal[] = dogs; // 数组在 TS 中有历史上的协变表现
函数返回值协变:
type GetAnimal = () => Animal;
type GetDog = () => Dog;
const getDog: GetDog = () => ({ name: "d", bark() {} });
const getAnimal: GetAnimal = getDog; // 安全
函数参数逆变:
type HandleAnimal = (value: Animal) => void;
type HandleDog = (value: Dog) => void;
能处理所有 Animal 的函数,当然也能处理 Dog;只能处理 Dog 的函数,不一定能处理普通 Animal。
完整版教学
一、先从类型关系开始
假设 Dog 是 Animal 的子类型。
Dog -> Animal
如果一个容器或函数包装后仍保持这个方向,就是协变;如果方向反过来,就是逆变。
方差不是 TypeScript 独有概念,它描述的是“包一层类型构造器后,子类型关系如何变化”。
二、返回值通常是协变
type Producer<T> = () => T;
如果你需要一个 Producer<Animal>,给你一个 Producer<Dog> 是安全的,因为 Dog 至少也是 Animal。
const dogProducer: Producer<Dog> = () => ({ name: "d", bark() {} });
const animalProducer: Producer<Animal> = dogProducer;
返回更具体的东西,使用方按更宽泛的类型接收,没有问题。
三、函数参数通常是逆变
type Consumer<T> = (value: T) => void;
如果某处会传入任意 Animal,你不能把只会处理 Dog 的函数塞进去。
const handleDog: Consumer<Dog> = dog => dog.bark();
如果把它当作 Consumer<Animal>,调用方传一只不会 bark 的猫就会出问题。
四、用表格记忆方向
| 位置 | 方差倾向 | 直觉 |
|---|---|---|
| 返回值 | 协变 | 产出更具体通常安全 |
| 参数 | 逆变 | 能接收更宽泛才安全 |
| 可读写容器 | 复杂或不安全 | 既读又写容易出洞 |
只读数组比可变数组更接近安全协变。
const dogs: readonly Dog[] = [];
const animals: readonly Animal[] = dogs;
五、双变来自兼容性妥协
TypeScript 为了兼容大量 JavaScript 回调写法,在某些方法参数位置保留双变行为。
这意味着参数类型检查比理论上更宽松。
interface Emitter {
on(handler: (event: Animal) => void): void;
}
实际项目中,事件回调、组件 props、库类型定义都可能遇到这种宽松性。
六、strictFunctionTypes 的作用
strictFunctionTypes 会让函数参数检查更接近逆变。
type Fn<T> = (value: T) => void;
开启后,普通函数类型赋值会更严格,减少“只会处理 Dog 的函数被当成 Animal 处理器”的风险。
但方法签名仍有兼容性差异,所以不能简单认为所有地方都完全严格。
七、常见误区与追问
- 误区:子类型赋值在任何位置都安全。 放到函数参数位置时方向可能反过来。
- 误区:协变、逆变只影响 class。 函数类型、泛型容器、回调都受影响。
- 误区:返回值和参数方差一样。 返回值通常协变,参数通常逆变。
- 误区:strictFunctionTypes 能消除所有双变。 方法参数等位置仍有兼容性设计。
- 追问:为什么只会处理 Dog 的函数不能当 Animal 处理器? 因为调用方可能传入非 Dog 的 Animal。
- 追问:readonly 数组为什么更安全? 它不能写入更宽泛元素,减少可变容器的类型漏洞。
八、加强记忆
记忆图:
Dog <: Animal
Producer<Dog> <: Producer<Animal> covariant
Consumer<Animal> <: Consumer<Dog> contravariant
面试回答时先讲 Dog 和 Animal,再分别套返回值和参数,最后补 TypeScript 的兼容性妥协和 strictFunctionTypes。