TypeScript中的协变和逆变

Published on
Reading time
7 min read
Likes
Authors

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

概念

关于子类型和父类型的概念其实容易搞混,我用通俗的话解释一下:在 TypeScript 的 class 中,子 class 继承父 class,然后添加了一堆的方法和属性等场景中:

typescriptEavan.dev
class Animal {
  name: string
}

class Cat extends Animal {
  breed: string
  age: string
}

在这里:

  1. 子类比父类拥有更多的属性
  2. 子类比父类更具体(下面有一个例外情况)。

子类型和父类型之间还有一个重要的概念:父类型可以被子类型赋值。你可以理解为:

  1. 父类型是一个熵很高、模糊的集合,这个集合里的元素都是模棱两可的(ambiguous)。(PS: 人生来混沌,我们通过不断地学习和迭代,才能减小熵,让自己更完美。)
  2. 子类型是基于父类型的,通过更具象的属性来规范性地扩展(extends,&)父类型,而不是通过或(|)这种方式来扩大熵的增量。

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

子类型能assign赋予父类型。

题目1

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

typescriptEavan.dev
type Type1 = {}
type Type2 = {}

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

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

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

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

题目2

tsxEavan.dev
type A = 3 | 4 | 5

type B = 3 | 4

请问谁是子类型?

  • 答案:B是A的子类型。

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

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


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

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

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

题目3

猜一猜 D 是什么类型:

tsxEavan.dev
type D = number[] extends readonly number[] ? true : false

答案是 true。

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

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

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

逆变的场景

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

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

tsxEavan.dev
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
TS Playground - An online editor for exploring TypeScript and JavaScript faviconTS Playground - An online editor for exploring TypeScript and JavaScript

猜一猜这时候 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 中,协变允许使用比原本预期的类型更加具体的类型。反之,逆变允许使用比原本预期的类型更加泛化的类型。

这下基本能理解什么是协变,什么是逆变了。当然,TS 默认是双向协变的,你需要开启一个 strictFunctionTypes 的选项才能享受到这种逆变的快感。