第 11 章

型变:协变/逆变/不变 — 为什么函数参数逆变

第11章:型变:协变/逆变/不变 — 为什么函数参数逆变

理解型变是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用协变/逆变/不变 — 为什么函数参数逆变?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

什么是型变

型变(Variance)回答一个核心问题:DogAnimal 的子类型时,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> 的原理剖析,以及如何用条件类型实现编译器级别的类型测试框架。

本章评分
4.8  / 5  (30 评分)

💬 留言讨论