Skip to content

TypeScript 中的协变与逆变

约 1498 字大约 5 分钟

typescript

2022-07-23

掌握类型系统的核心概念,让你的 TypeScript 水平更上一层楼

引言:为什么需要了解协变与逆变?

作为 TypeScript 开发者,你可能经常遇到一些看似"不合理"的类型错误。比如,为什么 string[] 可以赋值给 (string | number)[],但 (string | number)[] 却不能赋值给 string[]?这背后就是协变与逆变在起作用。

理解这两个概念不仅能帮你解决日常开发中的类型困惑,还能让你写出更安全、更健壮的类型代码。

核心概念:用现实世界类比理解

什么是协变(Covariance)?

简单理解:协变就是"保持方向一致"。

现实类比: 想象一个宠物店:

  • 如果你承诺"我会照顾任何品种的狗"
  • 那么你当然也能"照顾金毛犬"(因为金毛是狗的子类型)

在类型系统中,如果 金毛犬 的子类型,那么 照顾金毛犬[] 也是 照顾狗[] 的子类型。

// 基础类型
class Animal { name!: string }
class Dog extends Animal { bark() {} }
class Cat extends Animal { meow() {} }

// 协变示例:数组类型
let dogs: Dog[] = [new Dog(), new Dog()]
let animals: Animal[] = dogs // ✅ 允许:Dog[] 可以赋值给 Animal[]

// 因为:Dog extends Animal
// 所以:Dog[] 也 "extends" Animal[] (这就是协变)

什么是逆变(Contravariance)?

简单理解:逆变就是"方向反转"。

现实类比: 想象一个宠物领养活动:

  • 如果你承诺"我能处理任何动物相关的工作"
  • 那么你其实也能"处理狗相关的工作"(因为狗是动物的子类型)

注意这里的方向:处理动物处理狗 的子类型,方向反了!

// 函数参数类型的逆变
type AnimalHandler = (animal: Animal) => void
type DogHandler = (dog: Dog) => void

let handleAnimal: AnimalHandler = (animal: Animal) => {
  console.log(animal.name)
}

let handleDog: DogHandler = handleAnimal // ✅ 允许:AnimalHandler 可以赋值给 DogHandler

// 为什么?因为任何需要处理狗的函数,我给它一个能处理所有动物的函数,绝对安全!

TypeScript 中的协变与逆变实践

1. 数组和对象是协变的

这是最常见的协变场景:

// 数组协变
let stringArray: string[] = ['hello', 'world']
let stringOrNumberArray: (string | number)[] = stringArray // ✅ 允许

// 对象属性协变
interface Base {
  pet: Animal
}

interface Derived {
  pet: Dog // Dog 是 Animal 的子类型
}

let derived: Derived = { pet: new Dog() }
let base: Base = derived // ✅ 允许

2. 函数参数是逆变的

这是 TypeScript 中最重要的逆变场景:

// 函数类型比较
type ProcessAnimal = (animal: Animal) => void
type ProcessDog = (dog: Dog) => void

// 这是安全的:能处理所有动物的人,肯定能处理狗
let processDog: ProcessDog = (dog: Dog) => { /* 只处理狗 */ }
let processAnimal: ProcessAnimal = processDog // ❌ 错误!为什么?

// 等等,这里为什么报错?

实际上,在 TypeScript 的严格模式下,函数参数是逆变的:

// 正确的逆变示例
let processAnimal: ProcessAnimal = (animal: Animal) => {
  console.log(animal.name)
}

let processDog: ProcessDog = processAnimal // ✅ 允许!

// 理解:任何需要处理狗的地方,我给它一个能处理所有动物的函数,绝对安全
// 因为 processAnimal 能处理 Animal,当然也能处理 Dog(Dog 是 Animal 的子类型)

3. 函数返回值是协变的

type GetAnimal = () => Animal
type GetDog = () => Dog

let getDog: GetDog = () => new Dog()
let getAnimal: GetAnimal = getDog // ✅ 允许

// 理解:任何需要返回动物的地方,我给它一个返回狗的函数,完全没问题
// 因为狗就是动物(Dog extends Animal)

实际应用场景

场景 1:事件处理函数

// React 事件处理器的类型安全
interface BaseEvent {
  preventDefault: () => void
}

interface MouseEvent extends BaseEvent {
  clientX: number
  clientY: number
}

// 处理基础事件的函数
function handleBaseEvent(event: BaseEvent) {
  event.preventDefault()
}

// 处理鼠标事件的函数
function handleMouseEvent(event: MouseEvent) {
  console.log(`Click at: ${event.clientX}, ${event.clientY}`)
  event.preventDefault()
}

// 在需要 MouseEventHandler 的地方,我们可以使用更通用的 BaseEventHandler
let mouseHandler: (event: MouseEvent) => void = handleBaseEvent // ✅ 安全!

// 反过来就不安全:
// let baseHandler: (event: BaseEvent) => void = handleMouseEvent; // ❌ 危险!

场景 2:回调函数类型安全

// 数据处理器
type DataProcessor<T> = (data: T) => void

// 具体的处理器
const stringProcessor: DataProcessor<string> = (str) => {
  console.log(str.toUpperCase())
}

const anyProcessor: DataProcessor<any> = (data) => {
  console.log(data)
}

// 逆变的应用:更通用的处理器可以赋值给更具体的处理器
let processString: DataProcessor<string> = anyProcessor // ✅ 安全

// 协变的应用(在返回值中)
type DataFactory<T> = () => T

const stringFactory: DataFactory<string> = () => 'hello'
const anyFactory: DataFactory<any> = stringFactory // ✅ 安全

场景 3:泛型约束

// 协变在 Promise 中的应用
async function getDog(): Promise<Dog> {
  return new Dog()
}

// 可以赋值给返回更通用类型的函数
async function getAnimal(): Promise<Animal> {
  return getDog() // ✅ Promise<Dog> 可以赋值给 Promise<Animal>
}

// 逆变在函数参数中的应用
function processAnimal(processor: (animal: Animal) => void) {
  processor(new Animal())
}

function processDog(processor: (dog: Dog) => void) {
  processor(new Dog())
}

// 可以接受更通用的处理器
processDog((animal: Animal) => console.log(animal.name)) // ✅ 安全

记忆技巧与理解要点

协变记忆口诀

"子类型可以替代父类型" 数组、Promise、返回值都遵循这个规则

逆变记忆口诀

"处理父类型的函数可以替代处理子类型的函数" 函数参数遵循这个规则

安全原则

始终考虑类型安全。TypeScript 的所有类型规则都是为了确保运行时不会出现类型错误。

// 为什么这个不安全?思考一下:
let animalArray: Animal[] = [new Animal(), new Animal()]
let dogArray: Dog[] = animalArray // ❌ 错误!

// 如果允许,就会出现这样的运行时错误:
dogArray[0].bark() // 💥 运行时错误!animal 没有 bark 方法

总结

  • 协变:子类型关系保持不变(Dog[]Animal[] 的子类型)
  • 逆变:子类型关系反转((Animal) => void(Dog) => void 的子类型)
  • 应用
    • 数组、对象属性、Promise、函数返回值:协变
    • 函数参数:逆变
  • 核心思想:所有规则都是为了类型安全,防止运行时错误

理解了协变和逆变,你就能真正掌握 TypeScript 类型系统的设计哲学,写出更加类型安全的代码!

参考