15-函数类型:协变与逆变的比较
课程
1
开篇:用正确的方式学习 TypeScript
已学完
学习时长: 7分24秒
2
工欲善其事:打造最舒适的 TypeScript 开发环境
学习时长: 19分14秒
3
进入类型的世界:理解原始类型与对象类型
学习时长: 37分40秒
4
掌握字面量类型与枚举,让你的类型再精确一些
学习时长: 24分53秒
5
函数与 Class 中的类型:详解函数重载与面向对象
学习时长: 50分40秒
6
探秘内置类型:any、unknown、never 与类型断言
学习时长: 34分58秒
7
类型编程好帮手:TypeScript 类型工具(上)
学习时长: 33分34秒
8
类型编程好帮手:TypeScript 类型工具(下)
学习时长: 40分52秒
9
类型编程基石:TypeScript 中无处不在的泛型
学习时长: 43分46秒
10
结构化类型系统:类型兼容性判断的幕后
学习时长: 20分48秒
11
类型系统层级:从 Top Type 到 Bottom Type
学习时长: 45分16秒
12
类型里的逻辑运算:条件类型与 infer
学习时长: 52分44秒
13
内置工具类型基础:别再妖魔化工具类型了!
学习时长: 44分47秒
14
反方向类型推导:用好上下文相关类型
学习时长: 18分37秒
15
函数类型:协变与逆变的比较
学习时长: 21分38秒
16
了解类型编程与类型体操的意义,找到平衡点
学习时长: 4分27秒
17
内置工具类型进阶:类型编程进阶
学习时长: 83分3秒
18
基础类型新成员:模板字符串类型入门
学习时长: 32分58秒
19
类型编程新范式:模板字符串工具类型进阶
学习时长: 69分27秒
20
工程层面的类型能力:类型声明、类型指令与命名空间
学习时长: 53分23秒
21
在 React 中愉快地使用 TypeScript:内置类型与泛型坑位
学习时长: 78分9秒
22
让 ESLint 来约束你的 TypeScript 代码:配置与规则集介绍
学习时长: 57分13秒
23
全链路 TypeScript 工具库,找到适合你的工具
学习时长: 25分13秒
24
说说 TypeScript 和 ECMAScript 之间那些事儿
学习时长: 29分14秒
25
装饰器与反射元数据:了解装饰器基本原理与应用
学习时长: 81分53秒
26
控制反转与依赖注入:基于装饰器的依赖注入实现
学习时长: 69分6秒
27
TSConfig 全解(上):构建相关配置
学习时长: 55分33秒
28
TSConfig 全解(下):检查相关、工程相关配置
学习时长: 76分19秒
29
基于 Prisma + NestJs 的 Node API :前置知识储备
学习时长: 44分50秒
30
基于 Prisma + NestJs 的 Node API :项目开发与基于 Heroku 部署
学习时长: 46分40秒
31
玩转 TypeScript AST:AST Checker 与 CodeMod
学习时长: 85分18秒
32
感谢相伴:是结束,也是开始
学习时长: 3分21秒
33
漫谈篇:面试中的 TypeScript
学习时长: 4分25秒
juejin_logo copyCreated with Sketch.

全面梳理类型系统的层级关系:从 Top Type 到 Bottom Type 一节中,我们分析了 TypeScript 类型系统自下而上的层级,比较了原始类型、联合类型、对象类型、内置类型等的层级关系。但是,如果你使用过 TypeScript 很容易就会想到,我们好像漏了一点什么:函数类型有类型层级吗? 如果有,它的类型层级又是怎么样的?比如,下面这几个函数类型之间的兼容性如何?

type FooFunc = () => string;
type BarFunc = () => "literal types";
type BazFunc = (input: string) => number;

没什么头绪对吧?这一节,我们就来对比函数类型的类型层级,以及隐藏在这一比较幕后的理论——协变与逆变。这一篇文章中的概念我曾在此前的掘金文章中分享过,但大部分读者表示内容过于晦涩难懂,因此在这一节中我会换更接地气的方式来讲解。首先,我们通过逐步推导比较函数的类型层级,引出协变与逆变的概念,然后了解在 TypeScript 的内部定义是如何使用协变与逆变的,以及如何通过额外的配置启用这一检查。

本节代码见:Covariance and Contravariance

如何比较函数的签名类型?

首先要明确的是,我们不会使用函数类型去和其他类型(如对象类型)比较,因为这并没有意义,本文中只会对两个函数类型进行比较。

来看示例,给出三个具有层级关系的类,分别代表动物、狗、柯基。

class Animal {
  asPet() {}
}

class Dog extends Animal {
  bark() {}
}

class Corgi extends Dog {
  cute() {}
}

对于一个接受 Dog 类型并返回 Dog 类型的函数,我们可以这样表示:

type DogFactory = (args: Dog) => Dog;

在本文中,我们进一步将其简化为:Dog -> Dog 的表达形式。

对于函数类型比较,实际上我们要比较的即是参数类型与返回值类型(也只能是这俩位置的类型)。对于 Animal、Dog、Corgi 这三个类,如果将它们分别可重复地放置在参数类型与返回值类型处(相当于排列组合),就可以得到以下这些函数签名类型:

这里的结果中不包括 Dog -> Dog,因为我们要用它作为基础来比较

  • Animal -> Animal

  • Animal -> Dog

  • Animal -> Corgi

  • Dog -> Dog

  • Dog -> Animal

  • Dog -> Corgi

  • Corgi -> Animal

  • Corgi -> Dog

  • Corgi -> Corgi

直接比较完整的函数类型并不符合我们的思维直觉,因此我们需要引入一个辅助函数:它接收一个 Dog -> Dog 类型的参数:

function transformDogAndBark(dogFactory: DogFactory) {
  const dog = dogFactory(new Dog());
  dog.bark();
}

对于函数参数,实际上类似于我们在类型系统层级时讲到的,如果一个值能够被赋值给某个类型的变量,那么可以认为这个值的类型为此变量类型的子类型

如一个简单接受 Dog 类型参数的函数:

function makeDogBark(dog: Dog) {
  dog.bark();
}

它在调用时只可能接受 Dog 类型或 Dog 类型的子类型,而不能接受 Dog 类型的父类型:

makeDogBark(new Corgi()); // 没问题
makeDogBark(new Animal()); // 不行

相对严谨地说,这是因为派生类(即子类)会保留基类的属性与方法,因此说其与基类兼容,但基类并不能未卜先知的拥有子类的方法。相对欢脱地说,因为我们要让这只狗汪汪两声,柯基、柴犬、德牧都会,但如果你传个牛进来,这我就很难办了啊。

里氏替换原则:子类可以扩展父类的功能,但不能改变父类原有的功能,子类型(subtype)必须能够替换掉他们的基类型(base type)。

回到这个函数,这个函数会实例化一只狗狗,并传入 Factory(就像宠物美容),然后让它叫唤两声。实际上,这个函数同时约束了此类型的参数与返回值。首先,我只会传入一只正常的狗狗,但它不一定是什么品种。其次,你返回的必须也是一只狗狗,我并不在意它是什么品种。

对于这两条约束依次进行检查:

  • 对于 Animal/Dog/Corgi -> Animal 类型,无论它的参数类型是什么,它的返回值类型都是不满足条件的。因为它返回的不一定是合法的狗狗,即我们说它不是 Dog -> Dog 的子类型。

  • 对于 Corgi -> CorgiCorgi -> Dog,其返回值满足了条件,但是参数类型又不满足了。这两个类型需要接受 Corgi 类型,可能内部需要它腿短的这个特性。但我们可没说一定会传入柯基,如果我们传个德牧,程序可能就崩溃了。

  • 对于 Dog -> CorgiAnimal -> CorgiAnimal -> Dog,首先它们的参数类型正确的满足了约束,能接受一只狗狗。其次,它们的返回值类型也一定会能汪汪汪。

而实际上,如果我们去掉了包含 Dog 类型的例子,会发现只剩下 Animal -> Corgi 了,也即是说,(Animal → Corgi) ≼ (Dog → Dog) 成立(A ≼ B 意为 A 为 B 的子类型)。

观察以上排除方式的结论:

  • 参数类型允许为 Dog 的父类型,不允许为 Dog 的子类型。
  • 返回值类型允许为 Dog 的子类型,不允许为 Dog 的父类型。

你是否 get 到了什么?这里用来比较的两个函数类型,其实就是把具有父子关系的类型放置在参数位置以及返回值位置上,最终函数类型的关系直接取决于类型的父子关系。 “取决于”也就意味着,其中有规律可循。那么这个时候,我们就可以引入协变与逆变的概念了。

协变与逆变

我们上一节得到的结论是,考虑 Corgi ≼ Dog ≼ Animal,当有函数类型 Dog -> Dog,仅有 (Animal → Corgi) ≼ (Dog → Dog) 成立(即能被视作此函数的子类型,)。这里的参数类型与返回值类型实际上可以各自独立出来看:

考虑 Corgi ≼ Dog,假设我们对其进行返回值类型的函数签名类型包装,则有 (T → Corgi) ≼ (T → Dog),也即是说,在我需要狗狗的地方,柯基都是可用的。即不考虑参数类型的情况,在包装为函数签名的返回值类型后,其子类型层级关系保持一致。

考虑 Dog ≼ Animal,如果换成参数类型的函数签名类型包装,则有 (Animal -> T) ≼ (Dog -> T),也即是说,在我需要条件满足是动物时,狗狗都是可用的。即不考虑返回值类型的情况,在包装为函数签名的参数类型后,其子类型层级关系发生了逆转。

实际上,这就是 TypeScript 中的协变( covariance 逆变( contravariance 在函数签名类型中的表现形式。这两个单词最初来自于几何学领域中:随着某一个量的变化,随之变化一致的即称为协变,而变化相反的即称为逆变。

用 TypeScript 的思路进行转换,即如果有 A ≼ B ,协变意味着 Wrapper<A> ≼ Wrapper<B>,而逆变意味着 Wrapper<B> ≼ Wrapper<A>

而在这里的示例中,变化(Wrapper)即指从单个类型到函数类型的包装过程,我们可以使用工具类型来实现独立的包装类型(独立指对参数类型与返回值类型):

type AsFuncArgType<T> = (arg: T) => void;
type AsFuncReturnType<T> = (arg: unknown) => T;

再使用这两个包装类型演示我们上面的例子:

// 1 成立:(T -> Corgi) ≼ (T -> Dog)
type CheckReturnType = AsFuncReturnType<Corgi> extends AsFuncReturnType<Dog>
  ? 1
  : 2;

// 2 不成立:(Dog -> T) ≼ (Animal -> T)
type CheckArgType = AsFuncArgType<Dog> extends AsFuncArgType<Animal> ? 1 : 2;

进行一个总结:函数类型的参数类型使用子类型逆变的方式确定是否成立,而返回值类型使用子类型协变的方式确定

学习了函数类型的比较以及协变逆变的知识以后,你已经了解了如何通过“公式”来确定函数类型之间的兼容性关系,但实际上,基于协变逆变地检查并不是始终启用的(毕竟 TypeScript 在严格检查全关与全开的情况下,简直像是两门语言),我们需要通过配置来开启。

TSConfig 中的 StrictFunctionTypes

如果你曾经翻过 tsconfig 配置,你可能会注意到 strictFunctionTypes 这一项配置,但它在文档中的描述其实相对简略了些:在比较两个函数类型是否兼容时,将对函数参数进行更严格的检查When enabled, this flag causes functions parameters to be checked more correctly),而实际上,这里的更严格指的即是 对函数参数类型启用逆变检查,很自然的我们会产生一些疑惑:

  • 如果启用了这个配置才是逆变检查,那么原来是什么样的?
  • 在实际场景中的逆变检查又是什么样的?

还是以我们的三个类为例,首先是一个函数以及两个函数类型签名:

function fn(dog: Dog) {
  dog.bark();
}

type CorgiFunc = (input: Corgi) => void;
type AnimalFunc = (input: Animal) => void;

我们通过赋值的方式来实现对函数类型的比较:

const func1: CorgiFunc = fn;
const func2: AnimalFunc = fn;

还记得吗?如果赋值成立,说明 fn 的类型是 CorgiFunc / AnimalFunc 的子类型

这两个赋值实际上等价于:

  • (Dog -> T) ≼ (Corgi -> T)
  • (Dog -> T) ≼ (Animal -> T)

结合上面所学,我们很明显能够发现第二种应当是不成立的。但在禁用了 strictFunctionTypes 的情况下,TypeScript 并不会抛出错误。这是因为,在默认情况下,对函数参数的检查采用 双变( bivariant 即逆变与协变都被认为是可接受的

在 TypeScript ESLint 中,有这么一条规则:method-signature-style,它的意图是约束在接口中声明方法时,需要使用 property 而非 method 形式:

// method 声明
interface T1 {
  func(arg: string): number;
}

// property 声明
interface T2 {
  func: (arg: string) => number;
}

进行如此约束的原因即,对于 property 声明,才能在开启严格函数类型检查的情况下享受到基于逆变的参数类型检查

对于 method 声明(以及构造函数声明),其无法享受到这一更严格的检查的原因则是对于如 Array 这样的内置定义,我们希望它的函数方法就是以协变的方式进行检查,举个栗子,Dog[] ≼ Animal[] 是否成立?

  • 我们并不能简单的比较 Dog 与 Animal,而是要将它们视为两个完整的类型比较,即 Dog[] 的每一个成员(属性、方法)是否都能对应的赋值给 Animal[]

  • Dog[].push ≼ Animal[].push 是否成立?

  • 由 push 方法的类型签名进一步推导,Dog -> void ≼ Animal -> void 是否成立?

  • Dog -> void ≼ Animal -> void在逆变的情况下意味着 Animal ≼ Dog,而这很明显是不对的!

  • 简单来说, Dog -> void ≼ Animal -> void 是否成立本身就为 Dog[] ≼ Animal[] 提供了一个前提答案。

因此,如果 TypeScript 在此时仍然强制使用参数逆变的规则进行检查,那么 Dog[] ≼ Animal[] 就无法成立,也就意味着无法将 Dog 赋值给 Animal,这不就前后矛盾了吗?所以在大部分情况下,我们确实希望方法参数类型的检查可以是双变的,这也是为什么它们的声明中类型结构使用 method 方式来声明:

interface Array<T> {
    push(...items: T[]): number;
}

总结与预告

在这一节,我们学习了 TypeScript 函数类型的兼容性比较,这应该带给了你一些新的启发:原来不只是原始类型、联合类型、对象类型等可以比较,函数类型之间同样是能够比较的。而对我们开头提出的,如何对两个函数类型进行兼容性比较这一问题,我想你也有了答案:比较它们的参数类型是否是反向的父子类型关系,返回值是否是正向的父子类型关系。

如果用本章学到的新知识来说,其实就是判断参数类型是否遵循类型逆变,返回值类型是否遵循类型协变。我们可以通过 TypeScript ESLint 的规则以及 strictFunctionTypes 配置,来为 interface 内的函数声明启用严格的检查模式。如果你的项目内已经配置了 TypeScript ESLint,不妨添加上 method-signature-style 这条规则来让你的代码质量更上一层楼。

除了对自定义函数类型地比较,我们也了解了对于部分 TypeScript 内置的方法,会通过显式的 method 声明方式来确保在调用时,对参数类型检查采用双变而非逆变。

到这里,你已经了解了 TypeScript 类型系统中绝大部分的知识,我想在未来你再遇到奇怪的类型报错时,应该再也不会憋着气打开 StackOverflow 搜索,而是微微一笑胸有成竹地轻松解决,所凭借的自然就是我们对类型系统的深刻掌握。

类型工具、类型系统、类型编程这三辆马车我们已经解决了俩,在下一节,我们就将开始进入类型编程的世界里,此前我们所学的所有类型工具与类型系统知识将轮番上阵接受考验,而你也将从看见复杂工具类型就头疼,变成看见类型就两眼放光的 TypeScript 高高手。

扩展阅读

联合类型与兄弟类型下的比较

在上面我们只关注了显式的父子类型关系,实际上在类型层级中还有隐式的父子类型关系(联合类型)以及兄弟类型(同一基类的两个派生类)。对于隐式的父子类型其可以仍然沿用显式的父子类型协变与逆变判断,但对于兄弟类型,比如 Dog 与 Cat,需要注意的是它们根本就不满足逆变与协变的发生条件(父子类型),因此 (Cat -> void) ≼ (Dog -> void) (或者反过来)无论在严格检查与默认情况下均不成立。

非函数签名包装类型的变换

我们在最开始一直以函数体作为包装类型来作为协变与逆变的转变前提,后面虽然提到了使用数组的作为包装类型(Dog[])的,但只是一笔带过,重点还是在函数体方面。现在,如果我们就是要考虑类似数组这种包装类型呢?比如直接一个简单的笼子 Cage ?

先不考虑 Cage 内部的实现,只知道它同时只能放一个物种的动物,Cage<Dog> 能被作为 Cage<Animal> 的子类型吗?对于这一类型的比较,我们可以直接用实际场景来代入:

  • 假设我需要一笼动物,但并不会对它们进行除了读以外的操作,那么你给我一笼狗我也是没问题的,但你不能给我一笼植物。也就意味着,此时 List 是 readonly 的,而 Cage<Dog> ≼ Cage<Animal> 成立。即在不可变的 Wrapper 中,我们允许其遵循协变。

  • 假设我需要一笼动物,并且会在其中新增其他物种,比如兔子啊王八,这个时候你给我一笼兔子就不行了,因为这个笼子只能放狗,放兔子进行可能会变异(?)。也就意味着,此时 List 是 writable 的,而 Cage<Dog> Cage<Rabit> Cage<Turtle> 彼此之间是互斥的,我们称为 不变(invariant,用来放狗的笼子绝不能用来放兔子,即无法进行分配。

  • 如果我们再修改下规则,现在一个笼子可以放任意物种的动物,狗和兔子可以放一个笼子里,这个时候任意的笼子都可以放任意的物种,放狗的可以放兔子,放兔子的也可以放狗,即可以互相分配,我们称之为双变(Bivariant

也就是说,包装类型的表现与我们实际需要的效果是紧密关联的。

留言
Ctrl + Enter
全部评论(32)
阳树阳树的头像
删除
蔚来@FE前端
好懵
点赞
回复
沁浒丶的头像
删除
搬砖🧱码农
Corgi -> Corgi 和 Corgi -> Dog,参数类型不满足,因为这两个类型需要接受 Corgi 类型,它们的内部可能会用到 Corgi 的 cute 这个特性,而这个特性在 Dog 类型中不存在。
image
点赞
回复
twoSum的头像
删除
整本小册的难度在这一节达到了最高点
点赞
1
删除
(暂时
点赞
回复
逆风.微笑的头像
删除
web @ naver china
function transformDogAndBark(dogFactory: DogFactory) {
const dog = dogFactory(new Dog());
dog.bark();
}
这里的DogFactory类型 是不是可以改成其他的名字?

因为前面的定义是:
type DogFactory = (args: Dog) => Dog;
DogFactory 的参数类型只能接收Dog 和 Dog子类型。怎么文章后面分析说可以传入Animal。有点懵。
收起
点赞
回复
黄渡理工校长的头像
删除
“函数类型的参数类型使用子类型逆变的方式确定是否成立,而返回值类型使用子类型协变的方式确定。”
这里“是否成立”这四个字是不是多余了...感觉读不通啊
点赞
1
删除
(作者)
不是确定类型,是确定成立
点赞
回复
一串数字id的头像
删除
讲的很清楚!多看两遍就能看懂,作者好厉害
点赞
回复
菜菜的coder修养的头像
删除
好菜啊 ,一头雾水
点赞
1
删除
+1
1
回复
前端摸鱼儿的头像
删除
函数的协变逆变是不是只有当函数为参数的时候这一种情况下才会发生? 图中代码赋值操作算是结构性兼容吧
image
点赞
回复
前端家里蹲的头像
删除
前端开发工程师 @ 家里蹲
看不懂,好菜
点赞
1
删除
+1,理论的东西看起来都好晦涩,作者的基本功太强了,respect
1
回复
undefinedNullObject的头像
删除
对于 Corgi -> Corgi 与 Corgi -> Dog,其返回值满足了条件,但是参数类型又不满足了。这两个类型需要接受 Corgi 类型,可能内部需要它腿短的这个特性。但我们可没说一定会传入柯基,如果我们传个德牧,程序可能就崩溃了。
——不理解为什么 “可能内部需要它腿短的这个特性”
点赞
3
删除
(作者)
意为这里需要一个具体的子类型才能满足调用条件
点赞
回复
删除
拿干细胞和已分化的细胞举例,你的函数需要的是 肌肉细胞->肌肉细胞,但是你穿进去的是 神经细胞->肌肉细胞,这样自然是不成立的,而当你传入的参数为 干细胞->肌肉细胞 时,是成立的,因为干细胞可以分化为任意细胞[囧]
1
回复
删除
看了这个瞬间明白了所有疑惑[碰拳]
拿干细胞和已分化的细胞举例,你的函数需要的是 肌肉细胞->肌肉细胞,但是你穿进去的是 神经细胞->肌肉细胞,这样自然是不成立的,而当你传入的参数为 干细胞->肌肉细胞 时,是成立的,因为干细胞可以分化为任意细胞[囧]
点赞
回复
芹菜006的头像
删除
有收获!
点赞
回复
_Le_的头像
删除
有一个问题。按照下面这一点的话,makeDogBark(new Corgi())这里的Corgi也是dog的子类型啊,为啥不可以呢?
image
1
2
删除
(作者)
是可以的,但是可能我代码注释的位置造成误解了。。
1
回复
删除
哦哦,我又看了下,大概懂了,makeDogBark(new Corgi())这里调用的话是指Corgi实例是dog的子类型,所以可以,而参数逆变,返回值协变是指两个函数之间的比较,这两个不是同一种情况🤪
是可以的,但是可能我代码注释的位置造成误解了。。
点赞
回复
度123的头像
删除
前端
”现在,如果我们就是就是要考虑类似数组这种包装类型呢?比如直接一个简单的笼子 Cage ?“

“就是”重复了
点赞
1
删除
(作者)
Fixed
点赞
回复
吕帅辰的头像
删除
科员 @ 双鸭山市人民医院
最后的 非函数签名包装类型的变换 这一小节 完全没看懂[捂脸]有看懂的能帮我解释下吗
点赞
回复
Kuroo的头像
删除
前端开发
双变这里例子是否可以理解为:包装了联合类型的Cage,所以才能放任意种动物。(目前看着会有点误解为互斥类型满足了双变性)
包装类型的表现与我们实际需要的包装效果是紧密关联的,这样是否会更贴切一些。
点赞
回复
活轨的头像
删除
前端工程师
好难,这节完全没学懂呜呜呜
点赞
回复
codermao的头像
删除
web实习 @ 小红书
少了一个 哥哥
image
点赞
1
删除
(作者)
[可怜][可怜][可怜]
点赞
回复
exec的头像
删除
web前端开发
[How I understand Covariance & Contravariance in typescript](dev.to)感觉这篇函数的协变与逆变的理解看起来更加一目了然
2
回复
Mr.Pan酱的头像
删除
这个章节有点难理解,要是结合图文就更nice了
5
回复
FT搁浅的头像
删除
假设我需要一笼动物,并且会在其中新增其他物种,比如兔子啊王八,这个时候你给我一笼狗就不行了,因为这个笼子只能放狗,放兔子进行可能会变异(?)

这句话是不是写错了,应该是

假设我需要一笼动物,并且会在其中新增其他物种,比如兔子啊王八,这个时候你给我一笼兔子就不行了,因为这个笼子只能放狗,放兔子进行可能会变异(?)
收起
点赞
1
删除
(作者)
感谢 已更新~
点赞
回复
The action has been successful