协变与逆变
下文默认基于 TypeScript 5.x,并假设开启
strict: true。讨论函数参数时,默认关注strictFunctionTypes生效后的行为。
面试速答(30 秒版 TL;DR)
- 协变(covariance)看的是“子类型能不能替代父类型”这条方向是否保持不变。
- 逆变(contravariance)看的是“赋值方向反过来才安全”,最典型的就是函数参数。
- 在 TypeScript 里可以这样背:
- 返回值通常是协变;
- 函数参数通常是逆变;
- 方法参数为了兼容旧代码,很多场景仍带有**双变(bivariance)**色彩,这是 TS 的一个有意不完全严格的设计。
心智模型:生产者协变,消费者逆变
一句话记忆:
- 只“产出”值的位置,往往协变;
- 只“消费”值的位置,往往逆变。
1. 先用最朴素的例子建立直觉
class Animal {
name = "animal";
}
class Dog extends Animal {
bark() {}
}
已知:Dog 是 Animal 的子类型。
接下来要问的不是“继承关系是什么”,而是:
- 如果某个位置原本需要
Animal; - 那我塞一个
Dog进去,是否仍然安全?
这就是变型(variance)问题。
2. 协变:方向一致
2.1 返回值为什么通常协变
type CreateAnimal = () => Animal;
type CreateDog = () => Dog;
let makeAnimal: CreateAnimal;
const makeDog: CreateDog = () => new Dog();
makeAnimal = makeDog; // OK
这段赋值安全,因为:
- 调用方只要求拿到一个
Animal; - 实际返回一个更具体的
Dog完全没问题。
所以返回值位置天然适合协变。
2.2 只读容器为什么更容易协变
const dogs: ReadonlyArray<Dog> = [new Dog()];
const animals: ReadonlyArray<Animal> = dogs; // OK
因为只读容器只“产出”元素,不会把新的 Animal 写回 dogs 里,所以相对安全。
3. 逆变:方向反过来才安全
3.1 函数参数为什么逆变
type HandleAnimal = (value: Animal) => void;
type HandleDog = (value: Dog) => void;
let handleDog: HandleDog;
const handleAnimal: HandleAnimal = (value) => {
console.log(value.name);
};
handleDog = handleAnimal; // OK
为什么这是安全的:
handleDog这个位置承诺“未来会收到 Dog”;- 现在塞进去的是一个能处理更宽类型
Animal的函数; - 既然它连
Animal都能处理,那处理Dog当然也没问题。
反过来就不安全:
const onlyDog: HandleDog = (value) => {
value.bark();
};
// let handleAnimal2: HandleAnimal = onlyDog; // Error
因为 handleAnimal2 可能被传入普通 Animal,而 onlyDog 只会处理 Dog,这会有运行时风险。
3.2 标准答法
函数参数之所以逆变,是因为它是“消费位置”。
你想把一个函数赋给别人用时,关键要看:
- 它能不能接住“别人可能传进来的所有值”;
- 参数要求越宽,越安全;
- 参数要求越窄,越不容易替代别人。
4. TypeScript 里的几个特殊点
4.1 可变数组并不完全严格
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs;
animals.push(new Animal());
从类型安全角度看,这其实是不严谨的:
dogs本来应该全是Dog;- 但通过
animals这个别名,却被塞进了Animal。
这说明:
- TypeScript 在数组这类常见用法上做了兼容性取舍;
- 它不是一个完全严格、完全 sound 的类型系统。
面试里提到这一点,通常会加分。
4.2 方法参数常见“双变”现象
在 strictFunctionTypes 下,函数类型属性的参数检查更严格,但方法签名为了兼容大量旧代码,很多时候依然比理论上的逆变更宽松。
这也是为什么你会看到:
- 书上讲“函数参数逆变”;
- 实际写 TS 时某些对象方法又能赋过去。
这不是你记错了,而是 TS 的兼容性设计使然。
4.3 结构类型系统让问题更像“可赋值性”
TypeScript 不是名义类型系统,而是结构类型系统。
所以它讨论变型时,很多时候不是去问“声明上谁继承谁”,而是去问:
- 这个类型结构能不能安全赋给另一个位置;
- 这个位置对
T是读取、写入,还是既读又写。
5. 用泛型视角理解变型
5.1 生产者:协变
type Producer<T> = () => T;
T 只出现在返回值里,是输出位置,所以通常协变。
5.2 消费者:逆变
type Consumer<T> = (value: T) => void;
T 只出现在参数里,是输入位置,所以通常逆变。
5.3 既读又写:往往不变
type Box<T> = {
get(): T;
set(value: T): void;
};
这里的 T 既被读,又被写。
这类位置通常最难安全地放宽,理论上更接近不变(invariant)。
6. 高频题标准答法
6.1 什么是协变和逆变
标准口径:
- 协变是指子类型关系在泛型或复合类型里保持原方向;
- 逆变是指安全赋值方向需要反过来看;
- 最经典记忆法是“返回值协变、参数逆变”。
6.2 为什么函数参数要逆变
因为函数参数是消费位置。
一个函数想替代另一个函数,必须保证自己能接住调用方可能传入的所有值,所以参数类型通常需要更宽而不是更窄。
6.3 TypeScript 是不是完全严格遵守这些规则
不是。
TypeScript 为了兼容 JavaScript 生态,在数组、方法参数等地方保留了一些不完全 sound 的行为,所以面试里最好说“理论上”和“TS 实际实现”要分开讲。
7. 常见追问
7.1 什么时候该主动用 ReadonlyArray<T>
当你的 API 只读不写时。
这样既表达意图,也能减少因为可变数组带来的协变安全问题。
7.2 React 事件回调为什么看起来没那么严格
很多库类型为了易用性,会在回调参数上做兼容处理;再叠加 TypeScript 对方法签名的历史兼容,你会觉得“参数逆变”没有书上那么硬。
7.3 面试里怎么一句话说清
可以直接答:
- “协变是返回更具体类型仍可替代,逆变是参数要更宽才能安全替代;TS 理论上遵循这个方向,但实际为了兼容性并不完全严格。”
易错点 / 坑
- 把协变和“继承”混为一谈。变型讨论的是类型出现在某个位置时的赋值方向。
- 只背“参数逆变”,却不知道 TS 的方法参数、数组等地方并不完全严格。
- 没区分只读容器和可写容器,结果解释不清为什么
ReadonlyArray<T>更安全。 - 在泛型设计里既读又写同一个
T,却还希望它自由协变或逆变,这通常做不到。
速记要点(可背诵)
- 生产者协变,消费者逆变。
- 返回值通常协变,函数参数通常逆变。
- 既读又写的位置往往更接近不变。
- TypeScript 为兼容 JavaScript 生态,不是完全 sound 的类型系统。