浅谈Typescript的类型兼容

96 阅读5分钟

前言

使用 Typescript 开发的同学一定遇到过这个报错:

Type A is not assignable to type B. -> 类型 A 不可分配给类型 B

而这个错误的原因,就是类型不兼容。

如果你常常对此类错误感到费解,不妨往下读一读。

类型兼容

了解类型兼容之前需要先了解另一个小概念-结构子类型(也称为鸭子辨型):

是一种只使用其成员来描述类型的方式。

对于TS类型系统而言:像鸭子一样走路、游泳和嘎嘎叫的鸟就是鸭子。

interface Duck {
  walk: () => void;
  swim: () => void;
  gaga: () => void;
}

class WhateverType {
  walk = () => { };
  swim = () => { };
  gaga = () => { };
}

let duck: Duck;
// 完全 OK。
// 类型系统检测到 WhateverType 有和类型 Duck 一样的成员,则认为 WhateverType 实例也可以作为 Duck 的实例
duck = new WhateverType();

TS的类型兼容性就是基于结构子类型的。

什么是类型兼容

概念

TypeScript结构化类型系统的基本规则是,如果 A 要兼容 B,那么 B 至少具有与 A 相同的属性。

概念有点抽象,来举个🌰:

我们先来看看集合中子集的概念。

如果集合A的任意一个元素都是集合B的元素(任意a∈A则a∈B),那么集合A称为集合B的子集 。

而 上述提到的 A 兼容 B 条件是: “ B 至少具有与 A 相同的属性”

这个条件翻译到集合中即:B ⊇ A ,也就是 A ⊆B。

所以这个概念用集合解释就是:如果 A ⊆B , 则 A 兼容 B。

类型分配

如果 A 兼容 B,则TS类型系统会视为类型 B 可以分配给类型 A。(反之亦然)

再举个🌰,来看如下代码示例:

interface Animal {
  name: string;
}
interface Dog {
  name: string;
  wow: () => void;
}
let animal: Animal = {
  name: ''
};
let dog: Dog = {
  name: '',
  wow: () => console.log('wow')
};

animal = dog; // ok
dog = animal; // error 

因为TypeScript里的类型兼容性是基于结构子类型的,所以我们可以认为: 

Animal ⊆ Dog  => 则 Animal 兼容 Dog => 则 Dog 类型可以分配给 Animal 类型 => animal = dog(is ok)。

而 Dog ⊊ Animal , 同理可得 dog = animal (is error )。

二者的类型关系可用 extends 的来描述:Dog extends Animal 。(称 Dog 为 Animal 的子类型)

这也就是文章开头提到的常见报错中的一种。

一句话总结TS的类型分配:更具体的类型可以分配给更宽泛的类型。

PS:对于联合类型 type Parent = "a" | "b" ; type Child = "a";  child 比 parent 更具体。

有了这个理论基础,我们再来看TS是怎么处理高阶类型的兼容问题的。

高阶类型的兼容型

PS:TS官方没有高阶类型的概念。类比JS的高阶函数(函数本身的功能是 入参或返回值是函数 的函数),高阶类型即可接受一个泛型(也可以是确定的类型),并返回一个复合类型的 工具类型。如 Array就是一个高阶类型

TS类型系统在检测类型分配的时候,(常出现于 “=” 赋值、函数调用),会根据一些规则去判断它们的类型兼容性,而这个规则,就是接下来要讲的四个概念:协变、逆变、双向协变、不变 ?

假设我们将基础类型叫做T,复合类型叫做Complex,那么:

  • 协变表示Complex类型兼容和T的一致。
  • 逆变表示Complex类型兼容和T相反。
  • 双向协变表示Complex类型与T类型双向兼容。
  • 不变表示Complex类型与T类型双向都不兼容。

一下子引入了四个概念,莫慌,都比较好理解,下面一一举🌰说明:

协变 (Covariant)

以函数的返回值类型兼容性举例。

interface Animal {
  namestring;
}
interface Dog {
  namestring;
  wow() => void;
}

declare let animalAnimal;
declare let animalArrayArray<Animal>;
declare let dogDog;
declare let dogArrayArray<Dog>;

animal = dog; // ok
dog = animal; // error
animalArray = dogArray; // ok
dogArray = animalArray; // error
  • Animal 兼容  Dog  => Array 兼容 Array 

可以看到 Array 的兼容性和 T 的兼容性是一致的。—— 这就是协变

通俗易懂版:狗是动物 => 几只狗是几只动物。

逆变 (Contravariant)

以方法的入参举例。

interface Animal {
  namestring;
}
interface Dog {
  namestring;
  wow() => void;
}
type Func<T> = (t: T) => void;

declare let animalAnimal;
declare let animalFuncFunc<Animal>;
declare let dogDog;
declare let dogFuncFunc<Dog>;

animal = dog; // ok
dog = animal; // error
animalFunc = dogFunc; // error
dogFunc = animalFunc; // ok
  • Animal 兼容  Dog ,Dog 不兼容 Animal 
  • Func 不兼容 Func  ,Func  兼容 Func

可以看到 Func 的兼容性和 T 的兼容性是刚好相反的。—— 这就是逆变

那么为什么这样设计呢?因为在 Func 中可能会访问到 Func 中没有的成员,而 Func 中不可能会访问到 Func 中没有的成员。

通俗易懂版:我需要一条狗用于看家(Func),希望它看到陌生人的时候可以汪汪汪(dog.wow() ),但是你随便给了我一只动物,我让它看家,它却不会汪汪汪。这是我不能接受的。

双向协变 (Bivariant) 

理解了协变和逆变,再来看看后面两个就简单了。

双向协变是一个几乎不会用到的场景(在非严格模式下,函数的参数就是双向协变。因为会影响类型安全,所以在TS2.6版本以后,需要在tsConfig中手动开启 strictFunctionTypes )

interface Animal {
  namestring;
}
interface Dog {
  namestring;
  wow() => void;
}
type Func<T> = (t: T) => void;

declare let animalAnimal;
declare let animalFuncFunc<Animal>;
declare let dogDog;
declare let dogFuncFunc<Dog>;

animal = dog; // ok
dog = animal; // error
animalFunc = dogFunc; // ok
dogFunc = animalFunc; // ok

Animal 兼容 dog => animalFunc 和 dogFunc 互相兼容。

非特殊情况,推荐使用默认的逆变规则。

不变 (Invariant) 

这里需要构造一个复合类型。

interface Animal {
  namestring;
}
interface Dog {
  namestring;
  wow() => void;
}
interface Who<T> {
  item: T;
  func(item: T) => void
}
declare let animalAnimal;
declare let animalWhoWho<Animal>;
declare let dogDog;
declare let dogWhoWho<Dog>;

animal = dog; // ok
dog = animal; // error
animalWho = dogWho; // error Type '(t: Dog) => void' is not assignable to type '(t: Animal) => void'
dogWho = animalWho; // error Type 'Animal' is not assignable to type 'Dog'

Wow 不兼容 Wow,这是由于方法入参是逆变。

Wow 不兼容 Wow,这是由于 Dog 不兼容 Animal。

像这样,二者都不兼容的情况,就称为不变(不可变)。

总结

TS的类型兼容型的设计是非常巧妙的,既保证了类型安全,又为TS语言提供了很大了灵活性。对于刚刚从JS转到TS的同学,理解类型兼容是非常有必要的,能让你迅速地在TS的类型安全和JS的灵活之间游刃有余。

PS:如有讲的不对的地方,欢迎指正!