型变:协变/逆变/不变 — 为什么函数参数逆变
第11章:型变:协变/逆变/不变 — 为什么函数参数逆变
理解型变是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用协变/逆变/不变 — 为什么函数参数逆变?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 什么是型变
- 协变:返回值类型协变
- 逆变:函数参数逆变
Level 1 · 你需要知道的(1-3年经验)
什么是型变
型变(Variance)回答一个核心问题:当 Dog 是 Animal 的子类型时,Type<Dog> 是否也是 Type<Animal> 的子类型?
这个问题的答案取决于 Type 如何使用类型参数,有四种可能:
| 型变类型 | 定义 | 记忆方法 |
|---|---|---|
| 协变(Covariant) | Type<Dog> 可赋值给 Type<Animal> |
方向与子类型相同(co = 共同) |
| 逆变(Contravariant) | Type<Animal> 可赋值给 Type<Dog> |
方向与子类型相反(contra = 反) |
| 不变(Invariant) | 两方向都不可赋值 | 完全隔离 |
| 双变(Bivariant) | 两方向都可赋值 | TypeScript 历史遗留,不安全 |
// 先建立基础类型层次
class Animal {
breathe() {}
}
class Dog extends Animal {
bark() {}
}
class Cat extends Animal {
meow() {}
}
// Dog 是 Animal 的子类型(更具体),可以赋值给 Animal 变量
const dog: Dog = new Dog();
const animal: Animal = dog; // ✓ —— 基础:子类型赋值给父类型
协变:返回值类型协变
函数的返回类型是协变的——返回更具体类型的函数,可以赋值给返回更宽泛类型的函数变量。
type Supplier<T> = () => T;
const getDog: Supplier<Dog> = () => new Dog();
const getAnimal: Supplier<Animal> = getDog; // ✓ 协变,合法
// 为什么安全?
// getAnimal() 的调用方期望收到 Animal
// getDog() 返回 Dog,Dog 是 Animal,满足期望
// 调用方只会用 Animal 的方法,Dog 肯定都有
用实际代码说明:
function fetchUser(): { id: number; name: string; role: string } {
return { id: 1, name: "Alice", role: "admin" };
}
// fetchUser 返回类型比 fetchAny 更具体,可以赋值
type FetchAny = () => { id: number; name: string };
const fn: FetchAny = fetchUser; // ✓ 返回值有多余字段没问题
fn(); // 调用方只会访问 id 和 name,role 字段存在但被忽略
逆变:函数参数逆变
函数的参数类型是逆变的——接受更宽泛类型参数的函数,可以赋值给接受更具体类型参数的函数变量。
type Handler<T> = (value: T) => void;
const handleAnimal: Handler<Animal> = (a) => {
a.breathe(); // 只用 Animal 的方法
};
const handleDog: Handler<Dog> = handleAnimal; // ✓ 逆变,合法
为什么这是安全的?
// handleDog 的使用场景:调用方传入 Dog
handleDog(new Dog()); // 调用方确保传入的是 Dog
// handleAnimal 被赋值给 handleDog 后:
// 当传入 Dog 时,handleAnimal 接收到 Dog(Dog 是 Animal),
// 只调用 a.breathe(),Dog 肯定有这个方法 ✓
为什么反向不安全?
const handleDogSpecific: Handler<Dog> = (d) => {
d.bark(); // 调用了 Dog 特有的方法
};
// ✗ 如果允许这个赋值:
// const handleAnimal2: Handler<Animal> = handleDogSpecific;
// handleAnimal2(new Cat()); // 传入 Cat
// → 内部调用 cat.bark(),Cat 没有 bark 方法,运行时崩溃!
// TypeScript 正确地报错,阻止了这种赋值
const wrongAssign: Handler<Animal> = handleDogSpecific; // ✗ 类型错误
具体的现实场景:
// DOM 事件:MouseEvent 是 Event 的子类型
type EventCallback<T extends Event> = (event: T) => void;
const handleAnyEvent: EventCallback<Event> = (e) => {
console.log(e.type); // Event 上的属性
};
const handleMouseEvent: EventCallback<MouseEvent> = (e) => {
console.log(e.clientX, e.clientY); // MouseEvent 特有的属性
};
// ✓ 接受更宽泛事件的处理器,可赋值给接受更具体事件的处理器变量
const onMouseMove: EventCallback<MouseEvent> = handleAnyEvent;
// ✗ 反向不行——handleMouseEvent 依赖 clientX,非 MouseEvent 没有这个属性
const onAnyEvent: EventCallback<Event> = handleMouseEvent; // 类型错误
不变:泛型容器不变
可变容器(如 Array<T>)是不变的——Array<Dog> 和 Array<Animal> 互不可赋值。
const dogs: Array<Dog> = [new Dog()];
// ✗ 为什么不能赋值给 Array<Animal>?
const animals: Array<Animal> = dogs; // TypeScript 拒绝这个(如果开启严格检查)
不变性保护的场景:
// 假设允许赋值(假设场景)
const animals_hypothetical: Array<Animal> = dogs as any;
// 现在可以往 animals 里 push Cat:
animals_hypothetical.push(new Cat());
// dogs 和 animals_hypothetical 引用同一个数组!
// dogs[1] 现在是 Cat,但 dogs 的类型是 Array<Dog>
// dogs[1].bark() —— 运行时错误:Cat 没有 bark 方法
只读容器是协变的,因为不能写入,所以不存在污染问题:
// ReadonlyArray 是协变的
const readonlyDogs: ReadonlyArray<Dog> = [new Dog()];
const readonlyAnimals: ReadonlyArray<Animal> = readonlyDogs; // ✓ 合法
// 因为 ReadonlyArray 不允许 push/pop 等写操作
// 无法往 readonlyAnimals 里加入 Cat,安全
Level 2 · 它是怎么运行的(3-5年经验)
双变:TypeScript 的历史遗留问题
TypeScript 中方法签名(使用 method(x: T) 语法)是双变的,这是为了兼容早期代码和 ES5 类模式而保留的宽松检查。
interface Processor {
process(value: Dog): void; // 方法语法:双变
}
// ✓ 协变方向(同一般函数)
const p1: Processor = {
process(value: Animal) { value.breathe(); } // 接受更宽泛类型
};
// ✓ 逆变方向(危险!但 TypeScript 允许)
// 注意:这在 --strictFunctionTypes 下对函数属性有效,但方法仍然双变
const p2: Processor = {
process(value: Cat) { value.meow(); } // 接受更具体类型——运行时可能崩溃
};
// 修复:改用函数属性语法,获得严格的逆变检查
interface StrictProcessor {
process: (value: Dog) => void; // 函数属性语法:严格逆变
}
对比:
// 方法语法:双变(宽松)
interface A {
fn(x: Dog): void;
}
// 函数属性语法:逆变(严格),推荐
interface B {
fn: (x: Dog) => void;
}
TypeScript 4.7 型变注解
TypeScript 4.7 允许在泛型参数上显式标注型变,让编译器进行更精确的检查,也让代码更自文档化。
// in = 逆变(只消费 T,不产出)
// out = 协变(只产出 T,不消费)
interface Producer<out T> {
produce(): T;
}
interface Consumer<in T> {
consume(value: T): void;
}
interface Transformer<in TIn, out TOut> {
transform(value: TIn): TOut;
}
// 编译器会验证:
// - out T 不能出现在参数位置(消费位置)
// - in T 不能出现在返回值位置(产出位置)
// ✗ 违反约束:out T 出现在参数位置
interface WrongProducer<out T> {
produce(): T;
consume(value: T): void; // 错误:T 标记为 out,但这里用作输入
}
实际应用:
// 类型安全的事件发射器
interface EventEmitter<out T> {
on(handler: (event: T) => void): void; // 注意:T 出现在参数的参数里(逆变的逆变 = 协变)
emit(event: T): void; // 产出 T
}
// 带类型标注的只读容器
interface ReadonlyBox<out T> {
get(): T;
// set(value: T): void; // ✗ 不能有这个,违反 out 约束
}
型变的实际影响:EventHandler 问题
type EventHandler<T extends Event> = (event: T) => void;
// MouseEvent 是 Event 的子类型
// EventHandler<MouseEvent> 是 EventHandler<Event> 的超类型(逆变!)
const handleEvent: EventHandler<Event> = (e) => {
console.log(e.type);
};
const handleMouseEvent: EventHandler<MouseEvent> = (e) => {
console.log(e.clientX); // 需要 MouseEvent 特有属性
};
// ✓ 更通用的处理器可以赋值给更具体的处理器变量(逆变)
const onClick: EventHandler<MouseEvent> = handleEvent;
// ✗ 反向不行
const onAnything: EventHandler<Event> = handleMouseEvent; // 类型错误
// 在框架 API 中的影响
interface Component {
// 使用函数属性确保逆变检查
onClick: EventHandler<MouseEvent>;
onKeyDown: EventHandler<KeyboardEvent>;
}
// 这个组件只处理鼠标事件,不能传给期望通用事件处理器的地方
实用判断规则
// 规则 1:类型参数只出现在返回值位置 → 协变
interface Repository<out T> {
findById(id: string): T; // T 在返回值位置
findAll(): T[]; // T 在返回值位置
}
// 规则 2:类型参数只出现在参数位置 → 逆变
interface Serializer<in T> {
serialize(value: T): string; // T 在参数位置
}
// 规则 3:类型参数在两个位置都出现 → 不变
interface Codec<T> {
encode(value: T): Buffer; // T 在参数位置(逆变)
decode(data: Buffer): T; // T 在返回值位置(协变)
// 两者都有 → 不变
}
// 验证:
declare const stringCodec: Codec<string>;
// const anyCodec: Codec<unknown> = stringCodec; // ✗ 不变,不可赋值
对比表
| 场景 | 型变类型 | Dog extends Animal 时 |
|---|---|---|
() => T (返回值) |
协变 | () => Dog 可赋给 () => Animal |
(x: T) => void (参数) |
逆变 | (x: Animal) => void 可赋给 (x: Dog) => void |
Array<T> (可变容器) |
不变 | 两方向都不可赋值 |
ReadonlyArray<T> (只读容器) |
协变 | ReadonlyArray<Dog> 可赋给 ReadonlyArray<Animal> |
方法语法 { fn(x: T): void } |
双变 | 两方向都可(不安全!) |
函数属性 { fn: (x: T) => void } |
逆变 | 只有逆变方向(安全) |
Level 3 · 规范怎么定义的(资深)
型变(Variance)是类型系统理论中的核心概念。TypeScript 4.7 引入了显式型变注解(in/out),让编译器可以验证类型参数的使用位置是否与声明的型变方向一致。函数参数位置是逆变的(contravariant),返回值位置是协变的(covariant),两个位置都出现则是不变的(invariant)。方法语法的双变(bivariant)是 TypeScript 为兼容 DOM API 和 ES5 类模式保留的历史妥协——用函数属性语法 fn: (x: T) => void 可获得严格的逆变检查。
Level 4 · 边界与陷阱(所有人)
反模式:忽视双变导致的运行时错误
// ❌ 危险:方法语法双变,TypeScript 不会报错,但运行时崩溃
interface AnimalShelter {
addAnimal(animal: Animal): void; // 方法语法
}
class DogShelter implements AnimalShelter {
private dogs: Dog[] = [];
addAnimal(dog: Dog): void { // 收窄参数类型——双变下 TS 不报错
this.dogs.push(dog);
dog.bark(); // 如果传入 Cat,这里崩溃
}
}
const shelter: AnimalShelter = new DogShelter();
shelter.addAnimal(new Cat()); // 编译通过,运行时 TypeError
// ✓ 修复:用函数属性
interface StrictAnimalShelter {
addAnimal: (animal: Animal) => void; // 函数属性,严格逆变
}
class StrictDogShelter implements StrictAnimalShelter {
addAnimal: (animal: Animal) => void = (animal) => { // 必须接受 Animal
if (animal instanceof Dog) {
animal.bark();
}
};
}
本章小结
| 概念 | 关键规则 |
|---|---|
| 协变 | 类型参数只在"产出"位置(返回值)→ 子类型方向相同 |
| 逆变 | 类型参数只在"消费"位置(参数)→ 子类型方向相反 |
| 不变 | 类型参数在两个位置都有 → 两方向都不可赋值 |
| 双变 | 方法语法的历史遗留,用函数属性语法代替 |
in / out |
TS 4.7 型变注解,让编译器验证约束 |
| 实用规则 | 只产出 → 协变;只消费 → 逆变;两者都有 → 不变 |
下一章预告
第 12 章进入条件类型的高级应用——分布式条件类型、Awaited<T> 的原理剖析,以及如何用条件类型实现编译器级别的类型测试框架。