TypeScript:一文搞懂 infer

8,094

不久前曾做了一场分享,希望可以用 md 记录下来,主要是想深入浅出地帮助大家搞懂 TypeScript 中最难的一部分,协变逆变和 infer 以及前置知识。

类型兼容性

研究 TypeScript 的关键问题就是讨论两个类型之间的兼容性,我们简单地把类型之间的关系分为三种:

  • A 是 B 的子类型
  • A 是 B 的超类型
  • A 和 B 不兼容

需要注意的是,子类型与子类不同,仅描述两个类型之间的兼容性且子类型是包括自身的,就像集合子集也包括自己一样。

后续为了方便描述,A -> B 表示 B 是 A 的子类型

研究它有什么用呢? 本质就是 保证属性是存在。

比如 A 是 B 的子类型,那么类型为 A 的变量就可以赋给类型为 B 的变量了

let b : Animal;
let a : Cat;
b = a; // 正确
a = b; // 报错

Cat 可以保证有 Animal 上的属性,反之不成立。

鸭子类型

鸭子类型是 TypeScript 类型的最大特性:仅关注对象上的属性和方法,而不关注继承关系

比如

Class Duck{ swim } 

Class Dog{ swim,bark }

(伪代码)

在 TypeScript 中 Dog 是 Duck 的子类型,因为它满足了 Duck 上的所有方法和属性,而在 Java 中它们是不兼容的

也因为鸭子类型,我们可以使用集合概念去理解 TypeScript 类型之间的关系。

类型运算 「&」和「|」

image-20210819205241353.png

image-20210819205436558.png

&:求两个类型的并集,同名属性也对其类型 &

|:求两个类型的交集,只保留同名属性且也对其类型 |

两个类型运算有极为重要的关系:A | B -> A or B -> A & B

image-20210819210011276.png

我们可以按类的继承来理解:子类不但有父类上的所有属性和方法,还有自己的属性和方法。

那么, 显然 A 有 A|B 上所有属性, A & B 和 A 同理。

函数子类型

协变和逆变

这两个词仅仅是很简单的定义,描述两个过程:

协变:类型推导到其子类型的过程,A | B -> A & B 就是一个协变

逆变:类型推导到其超类型的过程

如何判断函数子类型?

为了方便描述,「Dog => Dog」 表示「参数为 Dog,返回值为 Dog 的函数」

其他类型的子类型我们很好判断,函数的子类型却很难,比如有关系 Animal -> Dog -> Shepherd

你能一眼看出下面哪个是 Dog => Dog 子类型吗?

A、Animal => Shepherd B、Shepherd => Aniaml

答案是 A

我们如果把视角划分,Dog => Dog 作为

参数的传入者:只能保证传入 Dog 参数,所以当我们定义参数为 Animal 时,只能使用 Animal 上的属性和方法,而 Dog 肯定有,就能保证类型的正确。

使用返回值者:保证只使用 Dog 方法,所以当我们定义返回值为 Shepherd,使用者只使用 Dog 上的属性和方法,而 Shepherd 肯定有,就能保证类型的正确。

所以,Dog => Dog -> Animal => Shepherd

也称 参数是逆变的,返回值是协变的。

至于你为什么这么少看到协变和逆变的概念,只因为 TypeScript 只有一处逆变,就是参数

infer

infer 的作用一言蔽之:推导泛型参数

看一个 infer 例子

type numberPromise = Promise<number>;
type n = numberPromise extends Promise<infer P> ? P : never; // number

Promise 输入了 number 获得一个新的类型,那么 infer 就可以通过已知的类型和获得它泛型反推出泛型参数

从返回值得到参数?在 JS 似乎很难想象,但是在 TypeScript 中 infer 就是用于做这个事情,不过注意它仅仅是推导,而非映射,遵循着一套规则,下文会具体讲解。

还有注意一点,infer 只能在 extends 的右边使用,infer P 的 P 也只能在条件类型为 True 的一边使用,下文会讲解这个限制的意义。

推导过程

以下代码为例

type getIntersection<T> = T extends (a: infer P,b: infer P) => void ? P : never;
type Intersection = getIntersection<(a: string, b: number)=> void> // string & number
  1. infer 必须在 extends 右侧使用,因为必须保证这个已知类型是由右侧的泛型推出来的,不然推导它的参数还有什么意义呢? 检查时会跳过使用了 infer 的地方。
  2. 遵循以下规则推导 P,有四种情况:
  • P 只在一个位置占位:直接推出类型
  • P 都在协变位置占位:推出占位类型的联合
  • P 都在逆变位置占位:推出占位类型的交叉(目前只有参数是逆变)
  • P 既在顺变位置又在逆变位置:只有占位类型相同才能使 extends 为 true,且推出这个占位类型

我们的例子属于第三种情况,P 都在逆变位置占位,最终就推出两个类型的交叉 string & number

那么为何是这种关系,可以朴素地解释一下:

首先回顾下类型运算关系和函数子类型:A | B -> A or B -> A & B,函数子类型的参数是逆变的

因为 (a: string, b: number)=> void extends (a: infer P,b: infer P) => void,所以(a: string, b: number)=> void(a: infer P,b: infer P) => void 子类型,所以 P 到 string 或者 number 是逆变,然而我们这里是反过来推 P,所以 string 或 number 到 P 是协变,最终就推出 string & numner

当 P 只在一个位置占位时,它推出来的类型就是一一对应的,比如 ParameterReturnType

小结

本文从三个方面渐进地讲解 infer 的知识:

类型兼容性,理解 A | B -> A or B -> A & B 的关系;

函数子类型以及协变逆变,因为只有参数是逆变的,所以需要理解函数子类型参数为何是逆变的;

最后讲解 infer 的作用,推导泛型参数,以及 infer 的推导规则,单处直推,多处协变推出联合,多处逆变推出交叉。