跳到主要内容

协变与逆变

在 TypeScript 的类型系统中,**协变(Covariance)逆变(Contravariance)**是描述类型之间"父子关系如何传递"的两个核心概念。理解它们能帮助你写出更安全、更灵活的泛型代码。

前置知识:子类型

在开始之前,我们需要先理解什么是子类型(Subtype)

class Animal {
name: string = '';
}

class Dog extends Animal {
breed: string = '';
}

class Greyhound extends Dog {
speed: number = 0;
}

这里的继承关系是:

Greyhound → Dog → Animal
(子类型) (父类型)

在 TypeScript 中,子类型可以赋值给父类型,这叫做向上兼容

let animal: Animal = new Dog(); // ✅ Dog 是 Animal 的子类型
let dog: Dog = new Animal(); // ❌ Animal 不是 Dog 的子类型

这就像说"狗是动物"成立,但"动物是狗"不成立。

什么是协变?

协变的意思是:如果 DogAnimal 的子类型,那么 F<Dog> 也是 F<Animal> 的子类型。方向一致

最直观的例子是数组

let dogs: Dog[] = [new Dog()];
let animals: Animal[] = dogs; // ✅ 协变:Dog[] 可以赋值给 Animal[]

DogAnimal 的子类型,所以 Dog[] 也是 Animal[] 的子类型——类型关系的方向保持不变,这就是协变。

协变的常见场景

1. 函数返回值是协变的

type Producer<T> = () => T;

let produceDog: Producer<Dog> = () => new Dog();
let produceAnimal: Producer<Animal> = produceDog; // ✅ 协变

函数"生产"数据时,返回子类型是安全的——你期望得到一个 Animal,实际得到一个 Dog,完全没问题。

2. 只读属性是协变的

type Box<T> = { readonly value: T };

let dogBox: Box<Dog> = { value: new Dog() };
let animalBox: Box<Animal> = dogBox; // ✅ 协变

什么是逆变?

逆变的意思是:如果 DogAnimal 的子类型,那么 F<Animal> 反而是 F<Dog> 的子类型。方向相反

最典型的例子是函数参数

type Handler<T> = (value: T) => void;

let handleAnimal: Handler<Animal> = (animal) => {
console.log(animal.name);
};

let handleDog: Handler<Dog> = (dog) => {
console.log(dog.breed);
};

// 逆变:Handler<Animal> 可以赋值给 Handler<Dog>
let dogHandler: Handler<Dog> = handleAnimal; // ✅
let animalHandler: Handler<Animal> = handleDog; // ❌ 不安全!

为什么函数参数是逆变的?

用一个生活中的例子来理解:

假设你需要一个"遛狗师"(Handler<Dog>),来了一个"动物护理员"(Handler<Animal>)。动物护理员能照顾所有动物,当然也能遛狗——安全

反过来,你需要一个"动物护理员"(Handler<Animal>),来了一个"遛狗师"(Handler<Dog>)。如果让遛狗师去照顾一只猫,他不会——不安全

用代码验证:

type Handler<T> = (value: T) => void;

let handleAnimal: Handler<Animal> = (animal) => {
console.log(animal.name); // 只访问 Animal 的属性,对任何动物都安全
};

let handleDog: Handler<Dog> = (dog) => {
console.log(dog.breed); // 访问 Dog 特有的属性
};

// ✅ 安全:handleAnimal 只用 name,Dog 肯定有 name
let test1: Handler<Dog> = handleAnimal;

// ❌ 危险:handleDog 要用 breed,但 Animal 不一定有 breed
let test2: Handler<Animal> = handleDog;

strictFunctionTypes 的作用

TypeScript 中有一个编译选项 strictFunctionTypes(包含在 strict 模式中),它控制函数参数的类型检查行为:

// strictFunctionTypes: true(默认 strict 模式)
type Handler<T> = (value: T) => void;
let animalHandler: Handler<Animal> = handleDog; // ❌ 报错,逆变检查

// strictFunctionTypes: false
let animalHandler: Handler<Animal> = handleDog; // ✅ 不报错,双变(不安全)
建议

始终开启 strict 模式,让 TypeScript 帮你捕获类型不安全的赋值。

注意:方法简写语法(method shorthand)不受 strictFunctionTypes 约束,始终是双变的:

// 函数属性语法 → 逆变(严格检查)
interface Strict {
handle: (value: Dog) => void;
}

// 方法简写语法 → 双变(宽松检查)
interface Loose {
handle(value: Dog): void;
}

in / out 显式标注(TypeScript 4.7+)

TypeScript 4.7 引入了 inout 关键字,让你可以显式声明泛型参数的型变:

// out = 协变(只出不进)
type Producer<out T> = () => T;

// in = 逆变(只进不出)
type Consumer<in T> = (value: T) => void;

// in out = 不变(既进又出)
type Processor<in out T> = (value: T) => T;

当实际使用违反声明时,TypeScript 会报错:

// ❌ 报错:T 被用在了输入位置,但声明为 out(协变)
type Wrong<out T> = (value: T) => void;

不变(Invariance)

当一个类型参数既出现在输入位置又出现在输出位置时,它既不是协变也不是逆变,而是不变的:

interface MutableBox<T> {
get(): T; // T 在输出位置 → 协变
set(v: T): void; // T 在输入位置 → 逆变
}

// 协变 + 逆变 = 不变
let dogBox: MutableBox<Dog> = /* ... */;
let animalBox: MutableBox<Animal> = dogBox; // ❌ 不安全

为什么不安全?如果允许赋值:

animalBox.set(new Cat()); // 通过 Animal 接口塞入一只 Cat
dogBox.get().breed; // 💥 运行时错误!实际是 Cat,没有 breed

总结速查表

型变方向关键字典型场景记忆口诀
协变Dog → AnimalF<Dog> → F<Animal>out返回值、只读属性、Promise<T>不进,方向
逆变Dog → AnimalF<Animal> → F<Dog>in函数参数、回调不出,方向
不变不能互相赋值in out可读写的容器又进又出,锁死
双变两个方向都可以方法简写(不安全)都行(但危险)

面试常见问题

Q1:什么是协变和逆变?用一句话解释

协变:子类型关系"顺着传"——DogAnimal 的子类型,Dog[] 也是 Animal[] 的子类型。

逆变:子类型关系"反着传"——DogAnimal 的子类型,但 Handler<Animal> 反而是 Handler<Dog> 的子类型。

Q2:为什么函数参数是逆变的,返回值是协变的?

  • 参数(输入):函数要"消费"这个值。能处理 Animal 的函数一定能处理 Dog,反之不然 → 逆变
  • 返回值(输出):函数要"生产"这个值。能生产 Dog 的函数返回的值一定满足 Animal 的要求 → 协变

Q3:Array<Dog> 赋值给 Array<Animal> 安全吗?

严格来说不安全,因为数组是可变的(可读可写),应该是不变的。但 TypeScript 为了实用性,将数组设计为协变的。如果你需要严格安全,使用 ReadonlyArray<T>

Q4:inout 关键字有什么实际用途?

  1. 文档作用:明确告诉使用者这个泛型参数的用途
  2. 编译检查:TypeScript 会验证实际使用是否符合声明
  3. 性能优化:帮助编译器更快地判断类型兼容性