TypeScript 中的逆变、协变、双变与不变

在 TypeScript 的类型系统中,逆变(Contravariance)协变(Covariance)双变(Bivariance)不变(Invariance) 是描述类型兼容性和子类型关系的重要概念。这些术语来源于类型理论,主要用于处理函数类型、泛型和接口的赋值规则。理解这些概念不仅能帮助我们编写更安全的代码,还能让我们更好地设计类型系统。本文将逐一讲解这四种变型,辅以代码示例,让你轻松掌握它们的含义与应用。

协变(Covariance):返回值类型的“向上兼容”

定义与原理

协变是指如果类型 AB 的子类型(记作 A <: B),那么 T<A> 也是 T<B> 的子类型。简单来说,子类型可以安全地“向上”转换为父类型。

应用场景

协变最常见于函数的返回值类型。在 TypeScript 中,函数返回的类型如果是子类型,则可以赋值给期待父类型的地方。

1
2
3
4
5
class Animal {}
class Dog extends Animal {}

let getDog = (): Dog => new Dog()
let getAnimal: () => Animal = getDog // 正确

这里,DogAnimal 的子类型,getDog 返回 Dog,而 getAnimal 期待返回 Animal。由于返回值类型是协变的,这种赋值是合法的。

为什么安全?

调用 getAnimal() 时,返回的是 Dog,但调用者只关心 Animal 的属性和方法。由于 Dog 继承了 Animal,它完全满足要求,因此协变在返回值中是安全的。

逆变(Contravariance):参数类型的“向下兼容”

定义与原理

逆变是指如果 AB 的子类型(A <: B),那么 T<B>T<A> 的子类型。与协变相反,逆变的方向是从父类型到子类型。

应用场景
逆变通常出现在函数参数类型中。在 TypeScript 的严格模式(strictFunctionTypes: true)下,函数参数默认是逆变的,即接受父类型的函数可以赋值给期待子类型的地方。

1
2
3
4
5
class Animal {}
class Dog extends Animal {}

let handleAnimal = (param: Animal) => {}
let handleDog: (param: Dog) => void = handleAnimal // 正确

handleAnimal 接受 Animal 类型参数,而 handleDog 期待 Dog 类型。由于 DogAnimal 的子类型,这种赋值在逆变规则下是合法的。

为什么安全?

调用 handleDog(new Dog()) 时,传入的是 Dog,而 handleAnimal 能处理任何 Animal(包括 Dog)。逻辑上不会出错,因此逆变在参数类型中是安全的。

双变(Bivariance):宽松的双向兼容

定义与原理

双变是指类型既支持协变又支持逆变,即在某些情况下,类型可以向上或向下兼容。这种行为通常出现在 TypeScript 的非严格模式(strictFunctionTypes: false)中。

应用场景

双变主要影响函数参数类型。当严格模式关闭时,TypeScript 允许参数类型双向赋值。

1
2
3
4
5
6
7
8
class Animal {}
class Dog extends Animal {}

let fn1 = (param: Dog) => {}
let fn2: (param: Animal) => void = fn1 // 非严格模式下正确

let fn3 = (param: Animal) => {}
let fn4: (param: Dog) => void = fn3 // 非严格模式下正确

在非严格模式下,fn1(要求 Dog)可以赋值给 fn2(要求 Animal),反过来 fn3(要求 Animal)也能赋值给 fn4(要求 Dog)。

注意事项

双变虽然灵活,但可能导致类型不安全。例如,fn2 可能传入 Animal 而非 Dog,而 fn1 无法处理这种情况。因此,严格模式下 TypeScript 只支持参数的逆变,以确保类型安全。

不变(Invariance):严格的类型匹配

定义与原理

不变是指类型 T<A>T<B> 之间没有任何兼容性,即使 AB 存在子类型关系。只有当类型完全相同时才兼容。

应用场景

不变通常出现在泛型类型同时作为输入(逆变)和输出(协变)时。由于两种变型要求冲突,TypeScript 要求类型保持不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Container<T> {
value: T // 输出(协变)
setValue: (x: T) => void // 输入(逆变)
}

class Animal {}
class Dog extends Animal {}

let dogContainer: Container<Dog> = {
value: new Dog(),
setValue: (x: Dog) => {},
}
let animalContainer: Container<Animal> = dogContainer // 错误!
  • dogContainersetValue 只接受 Dog,而 animalContainersetValue 要求接受 Animal,这违反了逆变规则。
  • 反过来,animalContainervalue 返回 Animal,而 dogContainer 期待 Dog,这违反了协变规则。 因此,Container<Dog>Container<Animal> 互不兼容,表现出不变性。

总结与对比

变型 定义 典型场景 示例兼容性
协变 A <: B -> T<A> <: T<B> 返回值类型 ()=>Dog -> () => Animal
逆变 A <: B -> T<B> <: T<A> 参数类型 (Animal) => void -> (Dog) => void
双变 既协变又逆变 非严格模式参数 双向兼容(不安全)
不变 T<A>T<B> 无关 输入+输出 Container<Dog> != Container<Animal>

实际意义

  • 协变与逆变:分别适用于返回值和参数的类型检查,是类型系统灵活性的体现。
  • 双变:非严格模式下的妥协,牺牲了安全性换取兼容性。
  • 不变:在复杂场景下强制类型一致,确保程序的正确性。

通过理解这些变型规则,我们可以更好地设计 TypeScript 的接口和泛型,避免类型错误。例如,在严格模式下优先考虑逆变的参数规则,或在泛型设计中明确类型的使用位置(输入还是输出)。