浅谈TypeScript中的协变和逆变
AI 摘要 (由 grok-3-fast-beta 生成)
本文深入探讨了TypeScript中父类型与子类型的关系,解释了协变与逆变的概念。协变允许子类型赋值给父类型,体现类型具体到宽泛的转换;逆变则多见于函数参数,允许更宽泛类型替代具体类型。文章通过代码示例和问题解析,阐明类型系统设计与安全检查的重要性。
关于这个主题,有很多概念需要了解,接下来通过一些问题来理解父类型、子类型,以及集合和不可变性等的概念。
概念
了解 variance(型变)?
当你把一个值赋给另一个变量、把数组传进函数,或者把函数当回调传递时,TypeScript 都要判断 A 类型的值是否能放进 B 类型的位置。这种“兼容判断”就涉及 型变(variance):
名称 | 方向 | 简单记忆 |
---|---|---|
协变 (Covariance) | 子类型 ➡️ 父类型 | “读”安全;比如 ReadonlyArray<Dog> 放进 ReadonlyArray<Animal> |
逆变 (Contravariance) | 父类型 ➡️ 子类型 | “写”安全;常见于函数参数位置 |
不变 (Invariance) | 双向都不行 | 必须完全相同;如 Map<K,V> |
双向协变 (Bivariance) | 两边都行 | TypeScript 为了易用性在某些函数类型上开的“方便之门” |
当你把一个值赋给另一个变量、把数组传进函数,或者把函数当回调传递时,TypeScript 都要判断 A 类型的值是否能放进 B 类型的位置。这种“兼容判断”就涉及 型变(variance):
关于子类型和父类型的概念其实容易搞混,我用通俗的话解释一下:
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 的语言系统才能更好地帮助我们检查出想要的问题。
所以
const animal1: Animal = new Animal('1'); // 不会报错
const animal2: Animal = new Cat('2','3','4'); // 也不会报错
既然子类型更加具体,那肯定也能直接赋予给更加宽泛的父类型的变量,我们可以得出一个结论:
子类型能assign
赋予父类型。
题目1
解释一下以下代码为什么不会报错:
type Type1 = {}
type Type2 = {}
type NamedVariable = (Type1 | Type2) & { name: string }
const b: NamedVariable = {
name: 'eavan',
}
其实 Type1 和 Type2 是空对象,空对象里的键是随意的。
但是 { name: string }
和空对象类型取交集后,依然是 { name: string }
。
题目2
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 是什么类型
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> —— 最经典的协变示例
协变:主要出现在 “只读” 场景
const dogs: ReadonlyArray<Cat> = [new Ca()]
const animals: ReadonlyArray<Animal> = dogs // ✅ 协变
为什么普通 Array<T> 不是 协变?
因为可写操作会破坏类型安全:
const catArray: Cat[] = [new Cat()]
const animalArray: Animal[] = dogArray // 如果这是允许的……
animalArray.push(new Animal()) // 这里就把“不会叫的动物”塞进了 dogArray!
caArray[0].bark() // 运行期崩溃
要点:只读容器(或任何不暴露写操作的 API)都能安全协变;一旦“可写”,协变便会导致潜在的运行时错误。
逆变的场景
先说结果,逆变基本发生在函数的场景下。
你可以通过以上几个小例子去分清谁能 assign 谁,但对于以下几个类型你怎么 assign 呢?是不是很懵逼?
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
选项时,才会更加严格地对函数参数进行逆变检查,让类型更安全。
type FnDog = (d: Dog) => void
type FnAnimal = (a: Animal) => void
let fn: FnDog = (d: Dog) => {}
fn = (a: Animal) => {} // ✅ 没报错?!
6. 总结口诀
- 只读用协变(ReadonlyArray, 只读属性)。
- 参数位置逆变(函数参数泛型)。
- 读写皆有,不变(Array, Map)。
- 顶层函数小心双向协变,开启 strictFunctionTypes 更安全。
一句话:能“放进去”(写)的类型要更宽,能“拿出来”(读)的类型要更窄 —— 这就是型变规则保障类型安全的核心思想。