跳到主要内容

协变与逆变

下文默认基于 TypeScript 5.x,并假设开启 strict: true。讨论函数参数时,默认关注 strictFunctionTypes 生效后的行为。

面试速答(30 秒版 TL;DR)

  • 协变(covariance)看的是“子类型能不能替代父类型”这条方向是否保持不变。
  • 逆变(contravariance)看的是“赋值方向反过来才安全”,最典型的就是函数参数
  • 在 TypeScript 里可以这样背:
    • 返回值通常是协变;
    • 函数参数通常是逆变;
    • 方法参数为了兼容旧代码,很多场景仍带有**双变(bivariance)**色彩,这是 TS 的一个有意不完全严格的设计。

心智模型:生产者协变,消费者逆变

一句话记忆:

  • 只“产出”值的位置,往往协变;
  • 只“消费”值的位置,往往逆变。

1. 先用最朴素的例子建立直觉

class Animal {
name = "animal";
}

class Dog extends Animal {
bark() {}
}

已知:DogAnimal 的子类型。

接下来要问的不是“继承关系是什么”,而是:

  • 如果某个位置原本需要 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 的类型系统。