为什么我读不懂 Typescript 高级类型?

本文是类型体操基础中的基础,主要针对已经开始使用 TypeScript,但对其高级用法仍然感到迷惑的读者。

阅读本文前,读者可以思考以下几个问题:如果你对它们已经了然于心,那么大可不必浪费时间接着阅读,因为本文接下来的章节将完全围绕这几个问题进行探讨。

  1. TypeScript 是否等于 JavaScript + 静态类型标注?
  2. type Exclude<T, U> = T extends U ? never : T
    为什么等式左侧的泛型T代表了联合类型,而右侧的T却是联合类型中的具体一个类型?为什么三元表达式会具有迭代的效果?
  3. infer 到底是什么?你是否可以给刚接触 TS 的开发者作出清晰明确的语言解释?
  4. 为什么对于变量来讲,Dog 可以赋值给 Pet,而对于函数来讲情况正好相反,Pet => void 可以赋值给 Dog => void

TypeScript 不是什么

TS 不是 JS + 静态类型标注

对于很多刚接触 TS 的开发者来说,最常见的误区就是类比其他强类型的 OOP 语言(Java/C#),把 TS 看作是 JS 的静态类型扩展,并沿用 JS 的思维方式来编写 TS 代码。事实上,TS 的类型系统和 JS 的运行时代码是完全独立 的,是两种不同的编程范式

TS 在设计时采用了大量以 JS 为基础的语法糖,这种设计使开发者的学习成本和迁移成本大大降低,但这也成为了两套系统被混淆的根本原因------先入为主的接受了 TS 是 JS 的静态类型扩展这一错误观念之后,看到纯类型系统编程的部分,也会错误的使用 JS 的思维方式,进而走向混沌。

日常业务中我们对 TS 90% 的使用,只是停留在它 10% 的语法糖上,而 TS 自身剩下 90% 的语言特性将是我们接下来要探讨的重点。

联合类型与隐式迭代

TS 是编译期的语言,并不会参与实际运行时,因此 TS 中很难出现类似 for 这样的命令式循环控制语句。对于
type Exclude<T, U> = T extends U ? never : T

也许你会认为如果可以写成
type Exclude<T, U> = for (P of T) { P extends U ? never : P }

会更好理解,但第二种表述方式属于一种 JS 的类比思考误区。

那么用 TS 应该如何思考呢?应该使用语言的特性:Distributive Conditional Types (分布式条件类型) 当出现
泛型 extends 条件 ? 分支1 : 分支2

这样的语句时,如果泛型是联合类型,那么这个语句的结果将是
类型1 extends 条件 ? 分支1 : 分支2 | 类型2 extends 条件 ? 分支1 : 分支2 | ...

由一个联合类型得到另一个联合类型,实现类似数学集合的分布式运算。

我们分析一下 handbook 中的例子:
type ToArray<Type> = Type extends any ? Type[] : never

当传入的泛型 Type 为 string | number 时,由于触发了分布式条件类型 这一特性,结果会按分布式规则拆解为
string extends any ? string[] : never | number extends any ? number[] : never

进一步运算得到
string[] | number[]

语句中的 extends any 看似毫无意义,但它实际上只是分布式条件类型 这一特性的触发条件,为了进行隐式迭代必须要 extends 至于 extends anyextends Type自身都是无所谓的。如果你感觉很难类比 JS 进行理解,请时刻提醒自己:TS 是一门全新的语言,不要类比 JS,要更直接的去学习它。

现在我们回头看 Exclude 工具类型:
type Exclude<T, U> = T extends U ? never : T
T extends U 触发了分布式条件类型 ,因此右侧表达式会对 T 的每一项调用,并返回联合类型。也就是检查 T 的每个子类型,如果它满足 extends U 就排除掉,否则就返回自身。

那么如果 T 不是联合类型呢?

如果 T 不是联合类型,分布式条件类型 就无法触发,因此它只是一个单纯的三元表达式。

这也是很多朋友难以理解的一个问题:T 既可以是联合类型,充当触发条件,又可以是迭代中的子类型,还可以什么都不是,就充当一个普通的类型:这确实是 TS 为了简洁性对清晰性作出的牺牲,只能靠多读多写来解决。

分布式条件类型 外,TS 还有其他触发隐式迭代的方法,比如 映射类型(Mapped Types),像常见的 Partial 工具类型:
type Partial<T> = { [P in keyof T]?: T[P] }

这里的 in keyof T 生成的键联合类型也会触发隐式迭代

infer:推导与模式匹配

举个例子:我们需要一个工具类型,它的作用是提取一个函数的返回值:
type ReturnType<T> = TypeOfReturnValue

首先我们要确定,泛型 T 需要是一个函数类型,否则返回值类型从何谈起?因此它应该形如:
type ReturnType<T> = T extends Function ? TypeOfReturnValue : never

但这样写显然不够具体:TypeOfReturnValueFunction 的一部分,我们需要拆解 Function 暴露出 TypeOfReturnValue 因此首先将 Function 扩写为:(...args: any[] => TypeOfReturnValue) 任意输入对应我们需要的输出类型

问题在于接下来我们如何声明一个变量,用它表示函数的返回值呢?这就要依赖 infer 关键词了

目前我们推导出的表达式是:
type ReturnType<T> = T extends (...args: any[]) => TypeOfReturnValue ? TypeOfReturnValue : never

使用 infer R 代替 TypeOfReturnValue 的位置,得到最终解:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

infer R 的含义是:声明一个变量,并让 TS 自动用它进行模式匹配

我们使用一个输入任意输出为 R 的函数去匹配泛型 T,如果匹配不到(T 不是一个函数)那么就返回 nerver,如果匹配上了(T 是一个函数),那么 R 将会自动被推导为符合该匹配模式的值,将 R 返回。

以一个加法函数为例:
const add = (sum1: number, sum2: number): number => sum1 + sum2
typeof add 作为 ReturnType 的泛型 T 时,我们相当于把这两个类型进行匹配:
(...args: any[]) => infer R
(sum1: number, sum2: number) => number

可以看出,匹配成功并且 R 会被推导为 number

这有点类似正则表达式 ,我们用/Anim(.?)l/去匹配 'Animal',最终 JS 推导出 (.?) 的位置替换成 a 就可以匹配成功,这里的 (.?) 类似 infer R 它们都是模板匹配的占位符(区别在于正则匹配的是字符串,infer 匹配的是类型)。infer 不好理解的原因在于它的思维过程是逆向的:不是先声明再使用 ,而是先使用再证明。把问题抛给 TS 引擎,让它来解决;我们只设计题干和分支处理,并通过占位符提取需要的信息(如果有必要)。

模式匹配 一定是有正反两个结果,因此 infer 也一定要和条件判断语句联合使用:你必须告诉 TS,匹配成功/失败分别要进行怎样的处理。

明白了模式匹配 的原理,我们还可以把这个工具类型改写为同时提取函数的参数和返回值:
type ParamsAndReturnType<T> = T extends (...args: infer P) => infer R ? [P, R] : never

协变 逆变 鸭式辨型

考虑开头的问题:对于变量来讲,Dog 可以赋值给 Pet,而对于函数来讲情况正好相反,Pet => void 可以赋值给 Dog => void

熟悉 Java/C# 语言的同学可能会类比 "多态性" 或者 "里氏替换" 这类名词,我们讲的更通俗一些:

对于变量的情况(协变)就像:你去宠物店买 Pet,店主给了你一条 DogDog 作为 Pet 的子类具有 Pet 的一切属性,所以没有问题。

但对于函数的情况(逆变)就像:你去宠物店请师傅帮自己的 Pet 洗澡,店里有一个师傅只接宠物狗的洗护(Dog => void),另一个接所有宠物的洗护(Pet => void),显然洗狗的师傅不能接你的活儿,否则一旦你的 PetCat 他就无法处理了,而全宠物洗护(Pet => void)的师傅则既可以接洗猫的工作(Cat => void),又可以接洗狗的工作(Dog => void)。

JS 是一门动态类型语言,因此天生就是鸭式辨型的:只要你具有鸭子的特性,你就是一只鸭子。而 TS 也继承了这一特征。

大家可能注意到了,我们在 TS 中进行的所有条件判断都是使用 extends 进行的,而不是 === 或者 is,这就是鸭式辨型的核心:我们从来不关心类型的名称或者引用,只要 A 具有 B 全部的属性,那么 A extends B 就成立。这叫做 结构化类型(Structural Typing) 和 Java/C# 的 名义类型(Nominal Typing) 完全不同。

终极考验

接下来我们要结合以上三章的全部内容,分析这个将联合类型转化为交叉类型的工具类型
type UnionToIntersection<T> = (T extends any ? (param: T) => any : never) extends (param: infer P) => any ? P : never

先看前半部分
T extends any ? (param: T) => any : never
extends any 应该让你有一种熟悉的感觉,没错,它正是分布式条件类型 的触发词。所以前半部分的目的是把联合类型转化为 (param: T) => any 的函数联合类型,其中每个函数的参数 T 都对应了联合类型中的一种类型。

再看后半部分:
UnionFunctions extends (param: infer P) => any ? P : never

这半部分使用了 infer 做模式匹配 ,函数联合类型满足函数模板吗?满足,前提是我们推断出的参数 P 要满足函数联合类型中每一个函数的参数类型。根据函数参数的逆变 原则我们可以知道:满足 T1 => any | T2 => any | ...的函数类型,只能是 P => any (P是T1, T2, ...的子类型),因此推导出的 P 会成为一个交叉类型。

我们可以做一个实验:将 infer P 推导的位置从函数参数改为函数返回值:
type WrongUnionToIntersection<T> = (T extends any ? (...param: any) => T : never) extends (...param: any) => infer P ? P : never

可以观察到,此时 WrongUnionToIntersection 已经不再具备联合转交叉的能力。

可能大家会对于函数类型的使用感到莫名其妙:这个需求本身确实和函数没有一毛钱的关系,但我们需要逆变 特性,而 TS 中唯一具有逆变 特性的就是函数参数,因此我们不得不通过函数参数的推导来启用逆变特性完成转换。

结语

TS 的类型系统既强大又反直觉,学习的重点在于:不要试图用 JS 的思维去理解它,而是把它作为一门全新的语言去学习。祝大家早日成为体操高手。

相关推荐
高木的小天才1 天前
HarmonyOS ArkUI安全控件开发指南:粘贴、保存与位置控件的实现与隐私保护实践
安全·ui·华为·typescript·harmonyos
高木的小天才2 天前
HarmonyOS ArkUI交互事件与手势处理全解析:从基础到高级实践
ui·华为·typescript·交互·harmonyos
芭拉拉小魔仙4 天前
【Vue3/Typescript】合并多个pdf并预览打印,兼容低版本浏览器
javascript·typescript·pdf
徐_三岁5 天前
Vue3 + TypeScript 实现 PC 端鼠标横向拖动滚动
vue.js·typescript
人工智能的苟富贵6 天前
使用 TypeScript 开发并发布一个 npm 包(完整指南)
javascript·typescript·npm
猫头虎6 天前
如何解决IDE项目启动报错 error:0308010C:digital envelope routines::unsupported 问题
javascript·ide·vue.js·typescript·node.js·编辑器·vim
啊花是条龙6 天前
使用 Axios 和 AbortController 实现请求控制和取消
react.js·typescript
parade岁月7 天前
TypeScript 全局类型声明文件规范性分析与归纳
前端·vue.js·typescript
胖方Hale7 天前
11. Typescript 泛型
前端·typescript
浪裡遊7 天前
TypeScript之基础知识
前端·javascript·typescript