浅谈TypeScript中的协变和逆变

6 分钟
grok-3-fast-beta Logo

AI 摘要 (由 grok-3-fast-beta 生成)

本文深入探讨了TypeScript中父类型与子类型的关系,解释了协变与逆变的概念。协变允许子类型赋值给父类型,体现类型具体到宽泛的转换;逆变则多见于函数参数,允许更宽泛类型替代具体类型。文章通过代码示例和问题解析,阐明类型系统设计与安全检查的重要性。

2.83s
~4996 tokens

关于这个主题,有很多概念需要了解,接下来通过一些问题来理解父类型、子类型,以及集合和不可变性等的概念。

概念

了解 variance(型变)?

当你把一个值赋给另一个变量、把数组传进函数,或者把函数当回调传递时,TypeScript 都要判断 A 类型的值是否能放进 B 类型的位置。这种“兼容判断”就涉及 型变(variance):

名称

方向

简单记忆 

协变 (Covariance)

子类型 ➡️ 父类型

“读”安全;比如 ReadonlyArray<Dog> 放进 ReadonlyArray<Animal>

逆变 (Contravariance)

父类型 ➡️ 子类型

“写”安全;常见于函数参数位置

不变 (Invariance)

双向都不行

必须完全相同;如 Map<K,V>

双向协变 (Bivariance)

两边都行

TypeScript 为了易用性在某些函数类型上开的“方便之门”


当你把一个值赋给另一个变量、把数组传进函数,或者把函数当回调传递时,TypeScript 都要判断 A 类型的值是否能放进 B 类型的位置。这种“兼容判断”就涉及 型变(variance):

关于子类型和父类型的概念其实容易搞混,我用通俗的话解释一下:

typescript
class Animal {
    constructor(name: string) {
        this.name = name
    }
    name: string
}

class Cat extends Animal {
    constructor(name: string, breed: string, age: string) {
        super(name)
        this.breed = breed
        this.age = age
    }
    breed: string
    age: string
}

let animal: Animal = {
    name: 'xx'
}
let cat: Cat = {
    name: 'xx',
    breed: 'xx',
    age: '22'
}

animal = cat      // ✅  子类型 ➡️ 父类型
cat    = animal   // ❌  编译错误:不能保证 animal 一定有 bark()

查看演示


看代码,在 TypeScript 的 class 中,子 class Cat 继承(extends)父 class Animal,然后添加了一堆的方法和属性等场景中,在此时:

1. 子类型比父类型更具体,包含更多约束或属性

2. 父类型比子类型更宽泛,约束更少

3. 子类型可以赋值给父类型

父类型就像是"车辆"这个大集合,而子类型则是"轿车"这个小集合。轿车是车辆的一种,所以轿车可以赋值给车辆,但反过来不行。

因为在 TypeScript 的类型系统中,我们通常要求类型越窄越精准越好,这是因为类型越窄,TS 的语言系统才能更好地帮助我们检查出想要的问题。

所以

typescript
const animal1: Animal = new Animal('1');         // 不会报错
const animal2: Animal = new Cat('2','3','4');    // 也不会报错

既然子类型更加具体,那肯定也能直接赋予给更加宽泛的父类型的变量,我们可以得出一个结论:

子类型能assign赋予父类型。

题目1

解释一下以下代码为什么不会报错:

typescript
type Type1 = {}
type Type2 = {}

type NamedVariable = (Type1 | Type2) & { name: string }

const b: NamedVariable = {
  name: 'eavan',
}

其实 Type1 和 Type2 是空对象,空对象里的键是随意的。

但是 { name: string } 和空对象类型取交集后,依然是 { name: string }

题目2

typescript
type A = 3 | 4 | 5

type B = 3 | 4

请问谁是子类型?

B是A的子类型

点击或悬停以查看内容

为什么呢?看起来 A 不是比 B 多一个可选类型吗?为什么 A 是父类型,而 B 是子类型?

因为这是一个联合类型。A 目前有三个可能性,而 B 只有两个可能性。这样来说,B 比 A 更加具体。


通过以上学习,你应该就能理解什么是协变了。

定义:如果 X<Cat> 能赋值给 X<Animal>,那么泛型 X 对其类型参数 协变

协变允许使用比原本预期的类型更加具体的类型。

但什么是逆变呢?应该就是反过来的概念。那么什么场景下是逆变呢?

题目3

猜一猜 D 是什么类型

typescript
type D = number[] extends readonly number[] ? true : false

true

点击或悬停以查看内容

这里面涉及到不可变性和可变性的概念。

类型安全:当一个函数接受一个 readonly number[] 作为参数时,它可以假设数组不会被修改。这保证了函数的行为是可预测的和安全的。

赋值规则:由于 readonly number[] 只是增加了不可变性约束,而没有改变数组元素的类型,因此从类型系统的角度看,number[] 赋值给 readonly number[] 是安全的。虽然 number[] 更加宽泛,但它符合 readonly number[] 的所有约束。

number[]readonly number[]的子类型,因为readonly number[]只是增加了不可变的约束,并没有改变数组元素的类型本身。

题目4

ReadonlyArray<T> —— 最经典的协变示例

协变:主要出现在 “只读” 场景

typescript
const dogs: ReadonlyArray<Cat> = [new Ca()]
const animals: ReadonlyArray<Animal> = dogs // ✅ 协变

为什么普通 Array<T> 不是 协变?

因为可写操作会破坏类型安全:

点击或悬停以查看内容
typescript
const catArray: Cat[] = [new Cat()]
const animalArray: Animal[] = dogArray   // 如果这是允许的……
animalArray.push(new Animal())           // 这里就把“不会叫的动物”塞进了 dogArray!
caArray[0].bark()                       // 运行期崩溃
要点只读容器(或任何不暴露写操作的 API)都能安全协变;一旦“可写”,协变便会导致潜在的运行时错误。

逆变的场景

先说结果,逆变基本发生在函数的场景下。

你可以通过以上几个小例子去分清谁能 assign 谁,但对于以下几个类型你怎么 assign 呢?是不是很懵逼?

typescript
type A12 = 1 | 2
type B123 = 1 | 2 | 3
type FnA12 = (p: A12) => void
type FnB123 = (p: B123) => void
type CCC = FnA12 extends FnB123 ? true : false

查看代码

猜一猜这时候 CCC 是 true 还是 false?

答案是 false。有人会说:不是 FnA12 更具体吗?明明参数只能选两个?

其实在函数里面,这套规则就不适用于之前所说的协变场景,并且这是函数的参数,维度又提升了一层,而不是我们之前比谁在谁的集合等这种小游戏。

那么我们要如何理解这种思维180°倒退的场景呢?

用逆向思维去解决这种问题

FnB123 的参数是 1 和 2,那么它只能处理传过来的参数为 1 或者 2 的场景。

但是 FnB123 就不一样了,它不管是 1 还是 2,甚至是 3,都能轻松应对。

那么是否可以说用前朝的剑(FnB123)就能斩本朝的官(当参数为 1 或者 2 时)?

这意味着 FnB123 能 assign FnA12。

在函数中,参数类型是逆变的,因为如果一个函数能接受更泛化的类型,那么它也可以接受更具体的类型。这意味着一个接受所有可能参数(如 1、2、3)的函数,可以安全地替代只接受特定参数(如 1 或 2)的函数。

协变逆变总结

协变:类型系统允许用比较“具体(窄)”的类型(子类型),去赋值给较“宽泛”的类型(父类型)。例如,子类可以赋值给父类,或更小的联合类型可以赋值给更大的联合类型。

逆变:在函数参数中,若某个函数能处理的输入范围更大,则它能“替代”只能处理少量输入的函数。也就是说,函数的参数类型是逆变的。


双向协变

顶层函数类型(直接作为变量或参数的函数)上,TypeScript 既允许协变也允许逆变,称为 可双向赋值。这就是为什么以下代码在默认设置下不会报错:

TypeScript 默认情况下对函数参数采取“双向协变”,即有时并不会完整地检查逆变。只有在开启 strictFunctionTypes 选项时,才会更加严格地对函数参数进行逆变检查,让类型更安全。


text
type FnDog = (d: Dog) => void
type FnAnimal = (a: Animal) => void

let fn: FnDog = (d: Dog) => {}
fn = (a: Animal) => {}          // ✅ 没报错?!



6. 总结口诀

  1. 只读用协变(ReadonlyArray, 只读属性)。
  2. 参数位置逆变(函数参数泛型)。
  3. 读写皆有,不变(Array, Map)。
  4. 顶层函数小心双向协变,开启 strictFunctionTypes 更安全。
一句话能“放进去”(写)的类型要更宽,能“拿出来”(读)的类型要更窄 —— 这就是型变规则保障类型安全的核心思想。