如何有效理解 Typescript 协变和逆变?

超集与子集

TypeScriptJavaScript的类型的超集。

超集,不得不说另外一个概念,子集,怎么理解这两个呢?
举个例子,如果一个集合A里面的所有元素集合B里面都存在,那么我们可以理解集合B是集合A的超集,集合A为集合B的子集。

image.png

这段话也许有些绕口,下面用代码举例,可以更加直观理解。

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}
复制代码

在这个例子中,AnimalDog的父类,DogAnimal的子类型,子类型的属性比父类型更多,更具体。

在类型系统中,属性更多的类型是子类型。在集合论中,属性更少的集合是子集。也就是说,子类型是父类型的超集,而父类型是子类型的子集。

记住一个特征,子类型比父类型更加具体,这点很关键。

可赋值性 assignable

assignable是类型系统中很重要的一个概念,当你把一个变量赋值给另一个变量时,就要检查这两个变量的类型之间是否可以相互赋值。

let animal: Animal
let dog: Dog

animal = dog // ✅
dog = animal // ❌error! animal 实例上缺少属性 'bark'
复制代码

从这个例子里可以看出,animal是一个「更宽泛」的类型,它的属性比较少,所以更「具体」的子类型是可以赋值给它的。因为animal上只有age这个属性的,dog拥有animal所拥有的一切类型,赋值给animal是不会出现类型安全问题的。

反之,如果dog = animal,那么后续使用者期望dog上拥有bark属性,当调用了dog.bark()就会引发运行时的崩溃。

从可赋值性角度来说,子类型是可以赋值给父类型的,也就是父类型变量 = 子类型变量是安全的,因为子类型上涵盖了父类型所拥有的的一切属性

T extends {},为什么可以extends一个空类型并且在传递任意类型时都成立呢?当搞明白上面的知识点,这个问题也自然迎刃而解了。

举例手写Exclude源码,可以理解更通透。

// T 中的类型如果是 U 的子集,返回 never,否则返回 T
type myExclude<T, U extends keyof any> = T extends U ? never : T;

const text: myExclude<number | string | boolean, number> = "this is myExclude";
复制代码

理解了超集与子集、可赋值性,以及他们之间的关系,接下来再去理解逆变和协变,可以更加通透。

逆变

有这样一段代码:

let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;

fn1 = fn2; // ❌ TS Error: 不能将fn2的类型赋值给fn1
复制代码

思考:明明fn2的形参包括了所有的fn1的形参为什么会报错?

调用fn1支持两个参数的传入,调用fn2支持三个参数的传入,假设fn2赋值给fn1成功了,那么调用fn1时,其实相等于调用fn2

如果执行了fn1 = fn2,当调用fn1时明显参数个数不匹配(由于类型定义不一致)会缺少第三个参数,显然是不安全的,是不被TS允许的。

反过来呢?

let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;

fn2 = fn1; // ✅
复制代码

按照上述思路,将fn1赋值给fn2,此时fn2内部指针已经被修改为fn1的指针。

调用fn1时只需要两个参数a: string, b: number,显然fn2的类型定义是满足该条件的。(在JS中函数调用时的实参个数大于定义时的形参个数是被允许的)。

所以,这是安全的可以被TS允许赋值。

通俗来说,上述函数参数少(父)的可以赋值给参数多(子)的,这种参数类型兼容性是典型的逆变。

换个方式来加深理解:

如果T ≤ U,那么F<U> ≤ F<T>成立,这就叫逆变

class Animal {
  doAnimalThing() {
    console.log("do animal thing.");
  }
}

class Dog extends Animal {
  doDogThing() {
    console.log("do dog thing.");
  }
}

// Cotra<Animal> ≤ Cotra<Dog>
type Cotra<V> = (input: V) => void;

const animalFn: Cotra<Animal> = (input) => {
  input.doAnimalThing();
};

const dogFn: Cotra<Dog> = (input) => {
  input.doDogThing();
};

let a: Cotra<Animal> = dogFn; // ❌ Animal 没有 doDogThing 方法
let b: Cotra<Dog> = animalFn; // ✅
复制代码

方法a定义入参为Animal,赋值是dogFn,调用方法a时如果传入Animal,由于Animal没有doDogThing方法,一定会执行出错。所以这里TS会提示错误。

但反过来就没问题。方法b传入DogDog继承Animal,是有doAnimalThing方法的。

协变

let fn1!: (a: string, b: number) => string;
let fn2!: (a: string, b: number) => string | number | boolean;

fn2 = fn1; // ✅
fn1 = fn2 // ❌ error: 不可以将 string|number|boolean 赋给 string 类型
复制代码

函数类型赋值兼容时函数返回值就是典型的协变场景。

根据上述代码,显然string | number | boolean联合类型是无法分配给string基本类型的,但是string是满足string | number | boolean其中之一,所以可以赋值给string | number | boolean组成的联合类型。

从函数运行角度来看,fn1 = fn2相当于调用了fn2自然string | number | boolean无法满足string类型的要求,所以TS会认为这是错误的。

换个方式来加深理解:

如果T ≤ U,那么F<T> ≤ F<U>也成立,这就叫协变

class Animal {
  doAnimalThing() {
    console.log("do animal thing.");
  }
}

class Dog extends Animal {
  doDogThing() {
    console.log("do dog thing.");
  }
}

// Co<Dog> ≤ Co<Animal>
type Co<T> = () => T;

const animalFn: Co<Animal> = () => {
  return new Animal();
};

const dogFn: Co<Dog> = () => {
  return new Dog();
};

let a: Co<Animal> = dogFn; // ✅ dogFn 返回 Dog,Dog 本身就是 Animal
let b: Co<Dog> = animalFn; // ❌ animalFn 返回 Animal,Animal 不一定是 Dog,无法执行 doDogThing
复制代码

函数的返回值类型要协变才安全,否则 TS 可能会报错。

协变与逆变的作用

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

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

参考文章:
www.icodebang.com/article/276…
mp.weixin.qq.com/s/3rRrpMyQq…

猜你喜欢

转载自juejin.im/post/7106882507982766117