TypeScript 中的协变与逆变到底是什么

类型变异(Type Variance)广泛存在于强类型的编程语言中,对于没有接触过类似 Java 语言的小伙伴来说,型变似乎有点难以理解。尤其是学习 TypeScript 时,可能会被它的类型系统绕得云里雾里,这篇文章我们就来对比 TypeScript 的类型层级,以及隐藏在幕后的理论------协变与逆变。

TypeScript 中的类型层级

类型的层级关系是静态类型语言的核心概念。首先,我们要弄清楚子类型超类型

如上图所示,给定两个类型 A 和 B,假设 B 是 A 的子类型,那么在需要 A 的地方都可以放心的使用 B。

很简单,有点像数学中集合的概念,即 B 是 A 的子集或 B 包含于 A。

如此,我们便可看出 TypeScript 各个类型之间的层级关系。比如:

  • Array 是 Object 的子类型;
  • Tuple 是 Array 的子类型;
  • 所有类型都是 any 的子类型;
  • never 是 所有类型的子类型;
  • 如果 Brid 类扩展自 Animal 类,那么 Brid 是 Animal 的子类型;

根据前面给出的子类型定义,我们便可得出:

  • 需要 Object 的地方都可以使用 Array;
  • 需要 Array 的地方都可以使用 Tuple;
  • 需要 any 的地方都可以使用 Object;
  • never 可在任何地方使用;
  • 需要 Animal 的地方都可以使用 Bird;

同理,超类型正好与子类型相反。在上图中,A 就是 B 的超类型。

型变

对于简单的数据类型,还是很容易判断它们的层级的,比如 number 包含在联合类型 number | string 中,那么 number 肯定是它的子类型。

但是对于较为复杂的类型(比如泛型),可能就不太好分析了。比如:

  • 什么情况下 Array<A>Array<B> 的子类型?
  • 什么情况下 对象 A对象 B 的子类型?
  • 什么情况下函数 (a: A) => B(c: C) => D 的子类型?

会发现,如果一个类型中包含其他类型,使用上述规则就很难判断谁是子类型,而且不同的语言在判断上也不尽相同。

为了便于理解 TypeScript 是怎么做的,我们先做如下规定:

  • A ≦ B,指 "A 类型是 B 类型的子类型,或者为同种类型"
  • A ≧ B,指 "A 类型是 B 类型的超类型,或者为同种类型"

结构(对象和类)的型变

我们以对象为例,去描述两种类型的用户,一个是已注册用户,它包含 idname,另一个是游客,只有 name

ts 复制代码
// 已注册的用户
type Account = {
  id: number;
  name: string;
}

// 游客
type Visitor = {
  name: string;
}

现在实现一个删除用户 id 的代码:

ts 复制代码
function deletaAccount(user: { id?: number; name: string }) {
  delete user.id;
}

const account: Account = {
  id: 12345,
  name: "Jerry"
};

deletaAccount(account);

deletaAccount() 方法接收一个对象,类型为 { id?: number; name: string },其中 id 是可选的,而传入的实际用户的 id 是确定的 number。所以,idnumber 的类型是 idnumber | undefined 的子类型。

因此,Account 作为一个整体是 { id?: number; name: string } 的子类型,所以 TypeScript 不会报错。

不出意外的话,要出意外了。

这里有一个安全问题,我们使用 deletaAccount() 删除了 id,但是 TypeScript 并不知道用户的 id 已被删除,所以 TypeScript 仍然认为 account.idnumber 类型。

可见,在预期使用超类型的地方,传入了子类型并不安全。但 TypeScript 并没有阻止我们,而是放宽了要求。

那么反过来呢?能不能在预期使用子类型的地方,传入超类型呢?

我们接着添加一个表示旧用户的类型,旧用户的 id 还能是 string 或没有:

ts 复制代码
type LegacyUser = {
  id?: number | string;
  name: string;
}

const legacyUser = {
  id: 'hahaha',
  name: 'Tom'
}

同样做删除操作,此时 TypeScript 报错了。

我们得到的答案是不能。不能在预期使用子类型的地方传入超类型。

TypeScript 的行为是这样的:对预期的结构,可以使用 ≦ 预期类型的子类型结构,但不能使用 ≧ 预期类型的超类型结构。

在类型上,我们就说 TypeScript 对结构(对象和类)的属性进行了协变(covariant)。

即如果想保证 A 对象可以赋值给 B 对象,那么 A 对象的每个属性都必须 ≦ B 对象的对应属性。

其实,协变 只是型变的四种方式之一:

  1. 不变(Invariant):只能是 T;
  2. 协变(Covariant):可以是 ≦ T;
  3. 逆变(Contravariance):可以是 ≧ T;
  4. 双变(Bivariant ):可以是 ≦ T 或 ≧ T;

在 TypeScript 中,每个复杂类型的成员都会进行协变,包括对象、类、数组和函数的返回类型。

不过有个例外,函数的参数类型进行逆变

函数的型变

先看函数本身,判断函数 A 是否 ≦ 函数 B,需要满足以下条件:

  1. 函数 A 的 this 类型未指定,或者 ≧ 函数 B 的 this 类型;
  2. 函数 A 的各个参数的类型 ≧ 函数 B 的相应参数;
  3. 函数 A 的返回类型 ≦ 函数 B 的返回类型;

细品几遍,你可能有疑问:

如果函数 A 是 函数 B 的子类型,那么函数 A 的 this 类型 和参数类型必定 ≧ 函数 B 的 this 类型 和参数类型。

但是函数 A 的返回类型却必定 ≦ 函数 B 的返回类型。

为什么两者的型变方向恰恰相反,而不都是 ≦ 哩?

函数返回类型的协变

为了回答这个问题,我们先定义三个 Class 类型(满足 A ≦ B ≦ C 的其他类型也可以):

ts 复制代码
class Animal {};

class Cat extends Animal {
  eat() {}
};

class Tom extends Cat {
  catchJerry() {}
}

其中,TomCat 的子类型,CatAnimal 的子类型。即 TomCatAnimal

定义一个参数为 eat 的函数,该函数预期想要一个 Cat 类型的参数:

ts 复制代码
function eat(cat: Cat): Cat {
  cat.eat();
  return cat;
}

看看 TypeScript 在校验类型时,允许我们把什么传给 eat()

ts 复制代码
eat(new Animal());
// 类型"Animal"的参数不能赋给类型"Cat"的参数。
//  类型 "Animal" 中缺少属性 "eat",但类型 "Cat" 中需要该属性。

eat(new Cat());
eat(new Tom());

可以传入一个 Cat 实例,或者一个 Tom 实例(因为 TomCat 的子类型),目前都能按预期正常工作。

可能有人会纳闷,不是说函数参数是逆变吗?为什么 Animal 不能传进去呢?请注意,上述结论是用来判断两个函数之间是否有层级关系的,而给函数传入参数则是在进行类型的校验,传入的类型必须为预期类型或其子类型,这里不要搞混了。

我们接着往下,再定义一个函数,现在该函数的参数变成了一个回调函数:

ts 复制代码
function clone(f: (c: Cat) => Cat): void {
  // ...
}

clone() 的参数是一个函数,该回调函数的参数是一个 Cat,返回值也是一个 Cat。什么类型的函数可以作为 f 传入呢?

我们控制变量,先测试返回不同的类型,看看有什么结果。

  1. 传入一个接收 Cat 并返回 Cat 的函数
ts 复制代码
function catToCat(cat: Cat): Cat {
  return cat;
}

clone(catToCat); // OK
  1. 传入一个接收 Cat 并返回 Tom 的函数
ts 复制代码
function catToTom(cat: Cat): Tom {
  // 这里的报错先无视
  return cat;
}

clone(catToTom); // OK
  1. 传入一个接收 Cat 并返回 Animal 的函数
ts 复制代码
function catToAnimal(cat: Cat): Animal {
  return cat;
}

clone(catToAnimal);
// 类型"(cat: Cat) => Animal"的参数不能赋给类型"(c: Cat) => Cat"的参数。
//  类型 "Animal" 中缺少属性 "eat",但类型 "Cat" 中需要该属性。

第三个报错了,TypeScript 发现返回的 Animal 缺少 Cat 中的某些属性,这可能会导致程序出现错误。因此在编译时,TypeScript 会确保传入的函数至少返回一个 Cat

clone() 预期一个 catToCat() 类型的回调函数,catToTom() 可以,catToAnimal() 就会报错,显然这三个函数类型层级关系是:catToTom()catToCat()catToAnimal()

这也应证了我们的结论:函数返回类型是协变的,即一个函数的返回值类型必须 ≦ 另一个函数的返回值类型。

函数参数类型的逆变

好,现在来看看回调函数的参数位置。

  1. 传入一个接收 Animal 并返回 Cat 的函数
ts 复制代码
function animalToCat(cat: Animal): Cat {
  return cat;
}

clone(catToCat); // OK
  1. 传入一个接收 Tom 并返回 Cat 的函数
ts 复制代码
function tomToCat(tom: Tom): Cat {
  return tom;
}

clone(tomToCat);
// 类型"(tom: Tom) => Cat"的参数不能赋给类型"(c: Cat) => Cat"的参数。
//   参数"tom"和"c" 的类型不兼容。
//     不能将类型"Cat"分配给类型"Tom"。

animalToCat() 的参数类型 AnimalCat 的超类型,符合逆变的特征,所以 animalToCat()catToCat() 的子类型,类型校验通过。

tomToCat() 的参数类型 TomCat 的子类型,并不符合逆变的特征,所以 tomToCat() 不是 catToCat() 的子类型,类型校验不通过。

这也很好理解,因为 Tom 有自己独有的技能 .catchJerry(),但不是所有的 Cat 都会抓杰瑞,如果这都不报错,那报错的就是程序了。

这表明,函数不对参数和 this 的类型做型变 。也就是说,一个函数是另一个函数的子类型,必须保证该函数的参数和 this 的类型 ≧ 另一个函数相应参数的类型。

tsconfig 中的 strictFunctionTypes

其实,考虑历史遗留问题,TypeScript 中的函数默认会对参数和 this 的检查采用双变 ,即逆变与协变都被认为是可接受的。如果想像上述示例中那样报错,得手动在 tsconfig.json 中启用 {"strictFunctionTypes": true} 标识。

当然,strict 模式包含 strictFunctionTypes,如果已经设置了 {"strict": true},那就不用再启用 strictFunctionTypes 标识了。

条件类型中的类型推断

infer 关键字为例,我们看下型变在泛型的类型推断中的应用。

现在在有条件类型的 extends 子语句中,允许出现 infer 声明,它会引入一个待推断的类型变量。 这个推断的类型变量可以在有条件类型的 true 分支中被引用。 允许出现多个同类型变量的 infer 。

  1. 在协变位置上,同一个类型变量的多个候选类型会被推断为联合类型
ts 复制代码
  type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;

  type T10 = Foo<{ a: string, b: string }>;  // string
  type T11 = Foo<{ a: string, b: number }>;  // string | number

T11 中结果可以是 string 也可以是 number,所以推断为 string | number

  1. 在逆变位置上,同一个类型变量的多个候选类型会被推断为交叉类型
ts 复制代码
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;

type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number

T21 中参数类型既要满足 a 中的 string 又要满足 b 中的 number,所以是 string & number,即 never

总结

协变意味着类型收窄,逆变意味着类型拓宽

对于简单数据类型或结构(对象和类)类型而言,类型需要收窄到能确保它最安全的类型。对于函数的返回值同样如此。

只是对于函数的参数而言,参数类型应该拓宽到能确保它最安全的类型(比如至少得拥有相同的基类)。

函数更倾向于范围大的,参数是狗接收狗,参数是动物也能接收狗。 所以这事兼容允许的,但是反过来,狗不能接收其他动物。

从类型安全的角度能更好地理解层级关系,虽然型变的方向有所不同,但目的都是一样的。

参考资料

类型兼容性---逆变/型变

TypeScript 全面进阶指南

在条件类型中的推断

相关推荐
腾讯TNTWeb前端团队1 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰5 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪5 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪5 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy6 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom6 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom6 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom7 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom7 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom7 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试