TS思考 - 逆变与协变

TypeScript思考篇又来了

之前听到同事分享逆变与协变,自己很感兴趣但一直没有去研究,今天就来康康~

extends

开始前可能要准备一下基础知识。extends关键字在TS中非常常用,它有如下几个功能

extends在TS中不光可以表示的继承和拓展,还可以表示类型的继承和拓展。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit {
  name?: string;
  unit: string; // <--- 不同点
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

在日常的开发中很容易遇到这样的场景,我们有两个类型,但是大部分都是相同的属性,这个时候这么写是可以的,但是就很蠢。

有很多方法可以去简化这种写法。这时候引入一个插曲

交叉类型和继承有什么区别

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

通过extends的方式可以继承某个类型的所有属性,新的类型只需要写自己特有的属性即可。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

type AddressWithUnit = BasicAddress & {
  unit: string;
}

通过&可以做到同样的效果。

那么问题来了,这两种方式都能实现相同的效果,有什么区别吗?通过一个简单的例子来看看

现在有这样一个例子,类型A和类型B都有一个属性test

interface A {
    test: string;
}

interface B {
    test: number;
}

如果我用上述的extends继承方式,会出现这个错误

可以理解为继承方式,子类型只是能使用父类型的内容,而不能改变

那么换成交叉类型&的方式来实现类型B

看似没什么异常

那么来定义一个变量看看

这个时候出现问题了

交叉类型可以理解为将两个类型做了交集,而属性test两个类型都有,string与number的交集就是空集(never)

那么function呢?

interface A {
    test: string;
    func: (a: boolean) => number;
}

interface B extends A {
    func: (a: boolean) => string
}

是一样的问题,同样会被TS认为是修改了父类型的内容

interface A {
    test: string;
    func: (a: boolean) => number;
}


type B  = A & {
    test: number;
    func: (a: boolean) => string
}

联合类型的结果也是一样的,会将类型A和类型B的func属性类型取交集

结论:

extends是一种不允许改动的效果,盲猜因为继承本身就是有层级关系的,继承类型本身无法去左右父类型

而交叉类型更像是一种平级关系,两个类型的内容取交集就是新类型的结果。

言归正传,什么是子类型

子类型大概有两种形式,第一种是对象类型

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}

Dog就是Animal的子类型,因为Dog是继承自Animal,这个很好理解

但需要再强调一点就是,DogAnimal包含更多的信息,多了bark()。所以在判断对象类型时,可以判断谁包含了更多的信息,谁就是子类型。

另一种是联合类型

type A = 'a' | 'b';

type B = 'a' | 'b' | 'c';

但是这种,谁是谁的子类型呢?

答案是AB的子类型。为什么呢?不是BA包含了更多的信息吗?

这里就会有点绕,慢慢来分析一下

首先,这是一个联合类型,也就是说,之间是的关系,满足其一就是符合类型的(比如’a’ ‘b’满足’a’就是对的)

这里A是可以安全的赋值给B的,因为A的所有可能性都被B涵盖了。

但是反过来Bc是没有办法赋值给A

所以判断哪个是子类型,就是比较具体的那个类型

协变

子类型的概念引入非常的重要,因为协变逆变本身就是存在于子类型(subType)超类型(superType)(就是我上面一直说的父类型,可能不够准确)之间。

现在有个例子

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}

在TS中子类型的变量是可以安全的赋值给父类型的。

接下来就是我的理解了,当子类型的变量安全的赋值给父类型时,其实就发生了一次协变。或者更官方一点的说法是满足协变的。

再来举几个协变的例子

type A = 'a' | 'b';

type B = 'a' | 'b' | 'c';

const a: A[] = []

const b: B[] = a;

可以看到AB的子类型,那么A类型的数组同样可以赋值给B类型的数组,这也是满足协变

逆变

还是来拿上面的例子来说

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}


let animalFunc = (a: Animal) => {
  return a.age;
}

let dogFunc = (dog: Dog) => {
  dog.age
  dog.bark()
}

Dog类型的变量赋值给Animal类型的变量是类型安全的,那么把参数为Dog类型的函数赋值给参数为Animal类型的函数是不是也可以呢?

答案是不行

因为把参数为Dog类型的函数赋值给参数为Animal类型的函数,也就意味着最终将会执行参数为Dog类型的函数里的内容

但是别忘了,现在这个函数依然是一个(a: Animal) => number类型的函数,我们很有可能就会传入一个Animal类型的参数

然后函数的内容却要求拥有bark属性,这时候就会报错。

但是反过来把参数为Animal类型的函数赋值给参数为Dog类型的函数,确是完全可以的。

可以发现函数方面的赋值方向与变量完全相反,这就是逆变。父类型可以赋值给子类型

TS类型系统

在一些其他编程语言里面,使用的是名义类型 Nominal type,比如我们在 Java 中定义了一个class Parent,在语言运行时就是有这个Parent的类型。因此如果有一个继承自ParentChild类型,则Child类型和Parent就是类型兼容的。但是如果两个不同的class,即使他们内部结构完全一样,他俩也是完全不同的两个类型

但是我们知道 JavaScript 中的复杂数据类型Object是一种结构化的类型。哪怕使用了 ES6的 class 语法糖,创建的类型本质上还是Object,因此 TypeScript 使用的也是一种结构化的类型检查系统

因此在 TypeScript 中,判断两个类型是否兼容,只需要判断他们的“结构”是否一致,也就是说结构属性名和类型是否一致。而不需要关心他们的“名字”是否相同。

协变逆变有啥用?

首先,是为了保证类型安全

其次,就是允许类型拥有一定的灵活性而不是死板的。

参考