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

类型变异(Type Variance)广泛存在于强类型的编程语言中,对于没有接触过类似 Java 语言的小伙伴来说,型变似乎有点难以理解。尤其是学习 TypeScript 时,可能会被它的类型系统绕得云里雾里,这篇文章我们就来对比 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

// 已注册的用户
type Account = {
  id: number;
  name: string;
}

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

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

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 中的协变与逆变到底是什么

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

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

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

type LegacyUser = {
  id?: number | string;
  name: string;
}

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

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

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 的其他类型也可以):

class Animal {};

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

class Tom extends Cat {
  catchJerry() {}
}

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

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

function eat(cat: Cat): Cat {
  cat.eat();
  return cat;
}

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

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

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

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

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

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

function clone(f: (c: Cat) => Cat): void {
  // ...
}

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

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

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

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

clone(catToTom); // OK
  1. 传入一个接收 Cat 并返回 Animal 的函数
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 的函数
function animalToCat(cat: Animal): Cat {
  return cat;
}

clone(catToCat); // OK
  1. 传入一个接收 Tom 并返回 Cat 的函数
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. 在协变位置上,同一个类型变量的多个候选类型会被推断为联合类型
  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. 在逆变位置上,同一个类型变量的多个候选类型会被推断为交叉类型
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 全面进阶指南

在条件类型中的推断

原文链接:https://juejin.cn/post/7340295805999415330 作者:蓝屏的钙

(0)
上一篇 2024年2月28日 下午4:48
下一篇 2024年2月28日 下午4:59

相关推荐

发表回复

登录后才能评论