一文吃透 TypeScript 类型编程(含精选类型体操编程题)

9,549 阅读18分钟

大家好,我是祯民。这几天我重温了一下 TypeScript 类型编程,这其实不是一个新的概念了,类型编程在我平时的开发中起到了不小的助力。所以虽然是一个老生常谈的话题,但是我还是觉得有必要写一篇文章和同学们一起回顾一下类型编程。

前言 - TypeScript 是否是有必要的?

在开始今天的学习之前,我想先简单聊一个话题,TypeScript 是否是有必要的,这个话题的想法来自于我在一篇 ts 博文下看到的一些评论,当然这边贴出来,并不是为了抨击或者是拉出来游行评论,我们可以先站在自己项目和技术思考的角度来思考这样一个问题。

image.png

关于这个话题,我个人的看法是,TypeScript 是很有必要的,它绝对不是一个前端的镣铐或是卷的工具,不仅仅是用于插件和库的开发,在企业业务的开发中更有决定性的作用,甚至我可以给大家下一个相对大胆且绝对的定论,使用纯 js 开发的企业业务迭代效率和质量上会远低于 ts,这个随着项目的复杂度提升会逐渐明显,缺乏 ts 定义将很容易出现类似类型匹配的问题,且会在项目交接和新人加入等阶段产生更多困难。

当然,这个同学的观点我也是可以理解的,为什么这条评论会产生这么多同学的共鸣,总结下来无非是两个痛点:

  • 类型自己定义,在本就复杂,开发人数众多且水平参差不齐的场景下,即使自己定义了类型,类型前后也未必可以连贯起来,显得在开发过程中额外定义类型的这一步就有点鸡肋
  • 对 ts 类型编程不熟悉或者了解甚微,导致很多场景下不知道该如何写它的类型定义,来保证类型守卫能够正常执行,项目中很多报红,warning,需要手动忽略或者换 any

第二个痛点咱们先晚点再聊,针对第一个痛点,全部自己定义的类型的确会很容易没办法前后连贯,这个问题随着开发者人数的增多以及每个人的水平不同会暴露得更加明显,但这个是有有解法的。

有一种语言类别叫接口描述性语言,即 IDL(Interface Description Language), 这个类别下常见的有 protobuf、thrift 等,这种语言简单来说,可以用来描述接口结构体和服务的数据结构,且可以根据定义的结构体自动生成带有指定输入、输出的接口代码,如 java、nodejs、golang 都是可以支持的,比如下面的例子

message QueryApproverRequest {

}
message QueryApproverResponse {
    repeated UserInfo approvers = 1; 
}

大家如果看这个就可以发现,和 ts 的定义很像,甚至可以说能够直接解析成前端的类型。如果接口服务基于上述的 protobuf 等 idl 生成,意味着前端和后端的类型将自动完全绑定,且这个过程是不需要增加很多成本的,完全可以通过工程化的方式,集成成命令行工具使用。

当然这并不是我们这篇文章的重点,大家可以自己下来查阅一下资料写个 demo 试试看,这里推荐一些资料大家可以看看:

通过这种方式,在大型企业项目中,便不再需要研发手写接口相关的基础类型,我们知道这种项目都是数据驱动的,在保证前后端的数据格式完全匹配的前提下,将完全杜绝使用了错误原生方法、遗漏边缘情况、类型被强转等各种类型相关的问题,整个开发效率会有质的提高。

image.png

image.png

在上面,我们还提到了第二个痛点,那就是大量使用 any, TypeScript 中的类型守卫会对异常的类型进行报错或是 warning,这个同样也是可以解决的,因为对 ts 类型编程不熟悉或者了解甚微,导致很多场景下不知道该如何写它的类型定义,加上可能也嫌麻烦,最后全篇 any 。

image.png

谈笑归谈笑,说实在的,全篇 any 那不如不用 TypeScript。那就是真的是为了用而用,无意义卷。这个问题的解决就是这篇文章我们介绍的重点 ---- TypeScript 类型编程。

Typescript 是支持类型编程的类型系统,也就是说,除了类型的一些基础使用外,我们可以对传入的类型参数(泛型)做各种逻辑运算,产生新的类型,这就是类型编程。因为相对难度较高,所以大家也戏称它为 TypeScript 类型体操。通过类型编程,我们就可以获取一些复杂场景下的类型,避免遇到阻塞就使用 any 的情况。

因为重点是类型编程,所以这篇文章并不会涉及 TypeScript 的基础知识,对 TypeScript 基础知识还不熟悉的同学可以查阅官网后再继续下面的学习。

下面我们进入正题,本文将从以下几个方向展开对类型编程的学习:

  • 类型运算:在这一小节中,我们将介绍 ts 中是如何进行类型编程的,整节会以贴近 javaScript 编程习惯的方式介绍,帮大家尽可能低成本地初步理解上手类型编程。
  • 类型的协变逆变:这一节我们将单独介绍一个知识点,协变和逆变。它是 TypeScript 活用类型系统的重要手段,利用协变和逆变可以编写更为灵活且健壮的代码,可以说是类型编程中最常见也最易出错的知识点。
  • 体操思路:在类型编程中,有一些常用的体操思路和套路,学会它们在遇到类似的类型场景时,可以举一反三。
  • 体操题精选:了解完类型编程的基础知识和套路后,我还给大家精选了一些比较经典的体操题,大家可以先自己在 playground 中实现,再对照题解对比一下思路,相信会有额外的收获。

官方有提供专门的练习区域,我们常称为 playground(www.typescriptlang.org/play?#code/…), 其中集成了 ts 的环境,并且对历史 case 进行了记忆,是练习和 case 复现的最佳环境,推荐大家在进行类型编程的时候使用。

为方便大家阅读理解,下文涉及代码模块会尽可能同时提供 markdown 代码块及 playground 示例。

类型运算

在上面我们有提到,TypeScript 除了直接使用基础类型(string、number)等用法外,还支持更复杂的类型编程来获取更多的类型,比如下面的例子

type shiftArr<arr extends unknown[]> = arr extends [unknown, ...infer restArr]
  ? restArr
  : never;

type footArr = shiftArr<[1, 2, 3]>;

如果我们到 playground 中执行上面的例子,可以得到 footArr 的结果是这样的,它是[1,2,3]去除掉首元素的结果数组,不过它不是一个变量,而是一个 ts 类型。 image.png 现在大家可能还不理解这个是什么写法,为什么上面的 ts 定义可以产生这样的效果呢?没关系,我们来看下面的这个 javaScript 方法。

const shiftArr = (arr: unknown[]) => {
  const [firstElement, ...restArr] = arr;
  return restArr || null;
};

const footArr = shiftArr([1, 2, 3]);

上面的函数实现是我们 ts 类型编程的等同实现,大家这样看应该会熟悉很多,结果同样是 [2,3], 唯一的区别在 ts 类型编程的一等公民是类型,而不像我们平时编程中,是一个存有值的内容变量。 image.png 下面我们就来具体聊一下,上面的 ts 类型实现为什么可以对应上面的函数实现?

常量、变量、函数的定义

在 ts 类型编程中,类型是它的一等公民,也就是说我们所有输入输出的结果预期应该都是类型。我们知道 ts 中可以使用 type 定义一个类型,即 type 我们可以理解为是 ts 类型编程中的定义关键词,等同于 constlet

type people = 'man' | 'woman';

除了常量、变量外,type 也可以用于定义函数,比如我们上面给出的 shiftArr 方法,在这个函数中,它的函数变量我们使用泛型来完成定义,泛型后面的 extends 大家可以理解为对这个变量的类型限制,比如arr extends unknown[] 意思就是 arr 至少需要满足 unknown[] 的类型。

所以这也就是type shiftArr<arr extends unknown[]> = ... 能和 const shiftArr = (arr: unknown[]) => {} 对应的原因。

条件判断

在上面我们提到 extends 可以用来限制参数的类型,extends 关键词它可以保证某个参数至少具备某个类型,同样地,我们也可以将 extends 与三元符来结合,达到类 if 的作用。

以上面的代码为例,arr extends [unknown, ...infer restArr] ? restArr : never 我们可以理解成,arr 可以按照 [unknown, ...infer restArr] 来解析吗?如果可以的得到有效值的话,就返回 restArr, 反之返回 never。

顺带一提,在 ts 中,never 表示永远不可能存在的值, 很多同学会感觉这玩意有啥用呢?这个类型兜底了所有我们预料之外的情况,并且可以在用户触发这种场景的时候提供编译报错进行警醒。

image.png

与 unknown 和 any 不同,never 是永远不能取得任何值的地方;unknown 则是可以取得任何值,但是不知道类型的地方;any 大家都很熟悉,使用它将丧失所有的类型检查。通常在类型编程中,遇到我们预料之外不知道该如何对应的值时(对应 null), 我们应该去使用 never。

类型提取

在上面的例子中,我们还有一个知识点没有介绍,infer 关键词。

infer 关键词只能用于 extends 右侧,即需要联动来使用,它的作用是可以完成类型的推导,我们知道在 ts 类型编程中,类型是一等公民,而不使用 infer 的时候,restArr 是无法被推导成一个类型的。

简单来说,在类型编程我们就可以使用yy extends xx<infer xxx> ? xxx : never的方式,提取出和 yy 相关的类型,比如这个例子中的去除第一个元素后,剩下的数组类型。

映射类型

除上面的场景外,还有一种特殊但常用的场景需要着重给大家介绍一下。

在 TypeScript 中对象、class 对应的类型是索引类型(Index Type),映射类型可以用于修改索引类型,也就是将一个集合映射到另一个集合中,比如下面的例子,我们通过定义 MapType 作为映射类型,就可以很轻松地将一系列对象转化成我们需要的类型。

type MapType<T> = {
    [Key in keyof T]: [T[Key], T[Key], T[Key]]
}

type res = MapType<{a: 1, b: 2}>;

在这个 case 中,我们遍历了输入参数的泛型 key,并转换成了一个新的对象, playground 中体现如下

image.png

在我们需要转换的类型为索引类型时,我们都可以采用类似上面的写法{[Key in keyof xxx]: [//... 用Key搞点事情]}, 其中 keyof 可以接收一个索引类型,并获得索引类型 xx 中的所有索引组成的联合类型,而 in 则可以对联合类型完成遍历。为帮助大家理解,我提供了下面这个 playground, 大家可以对照琢磨一下。

image.png

类型的协变逆变

TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法。这是类型检查做的事情,遇到类型安全问题会在编译时报错。子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是“型变(variant)”(类型改变)。

这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变(covariant),一种是父类型可以赋值给子类型,叫做逆变(contravariant)。

对于字面量类型采用协变的变换规则,即子类型 => 父类型, 最简单的就是下面的例子,相信大家很好理解,因为 a 具备 string 的可能,没办法直接赋值给 b。所以这就是协变,对于字面量类型的场景,父类型没办法赋值给子类型。

image.png

而对于函数的参数类型,采用完全相反的规则,即逆变,允许父类型 => 子类型,因为函数中是以父类型进行的约束,赋值一个能力更详细的子类型反而会造成意料之外的问题,例如下面的 case

image.png

协变和逆变在类型体操的时候会常常出现,大家要着重关注一下。当然不论是协变还是逆变,它们首先得是型变。型变都是针对父子类型来说的,非父子类型自然就不会型变也就是不变(invariant)。ts 中父子类型的判定是按照结构来看的,更具体的那个是子类型。

体操思路

其实看到这里大家已经掌握了体操的大部分知识了,剩下还有一些特殊场景的套路,我们再一起结合例子看看,也相当于对前文知识的一个巩固。

类型提取

类型提取其实上文已经有提到了,这个是类型编程中最为常用的技巧,可以说绝大部分的子类型处理可能都有它的影子,这里就不再多谈了,还不是很明白的同学们可以翻看上面的讲解,下面我们结合一个案例再巩固一下。

Q:如何获取某个函数所有参数的类型?

A:参考该 playground,我们只需要将函数的参数调整为 function 类型后,使用 extends 配合三元选择符和 infer 提取出 params 后进行返回即可。

image.png

需要注意的是,在上面的例子中,我们传入本身是 function 的 otherFunc 给 getFuncParams 前,使用了 typeof 提取了它的类型后再传入, 因为在类型编程中类型才是一等公民,我们并不能直接传入一个变量或是函数

递归处理

类型编程所提供的能力并不完全等同于一个普通的编程语言,它是不支持循环的,对于一些需要循环的场景我们不能通过单次类型提取或是直接处理得到我们需要的结果。虽然不支持循环,但是 ts 的类型编程可以支持递归的实现,通过这种方式我们也可以处理这一类复杂场景,我们来看下面的案例。

Q:如何深度(即也要处理子对象)将一个对象的属性转换为 readonly?

A:这个场景涉及到我们上文提到的两个知识点了,一个是映射类型,另一个就是递归,我们需要先遍历对象的所有key,并将每个 key 转化为 readonly,对应 key 映射的 value 我们需要做一个判断,如果这个 value 对应 object 类型,那么我们就对这个 value 再执行一遍我们定义的这个类型函数,反之,直接返回。具体可以参考该 playground

image.png

需要注意的是,这里额外加上了T extends any,是为了触发对完整类型的计算,ts只会对用到的类型展开计算,所以不加的话,后续的 readonly 会由 deepReadonly 代替,而不是深度计算

image.png

体操题精选

到这里类型体操的知识点就都讲完了,剩下的就是实操了,这里推荐大家可以关注一下 type challenge,里面有社区大家一起汇总的体操题,用于平时的训练巩固,或是参考别人的思维相信都会有不错的效果。

为了方便大家练习大家精选了几道体操题,大家可以结合 playground 练习,并对照题解进行巩固。

数组元素提取第一个元素的类型

这道题算是开胃菜,相信经过前文的学习对大家只是小意思了,进行简单的类型提取即可,参考 playground

image.png

字符串替换

这道题类似于实现 string 当中的 replace 原生方法,也很容易,如果一下没有思路的同学可以想想在 javaScript 中这道题应该怎么实现,然后用 ts 再对照一遍加深印象,参考 playground

image.png

去除字符串中的空白字符

这道题就相对复杂一些,需要同时用到递归和两个类型函数来组成,因为我们不知道有多少空白字符,所以需要递归按方向去除空白字符,最后将左右两边的两个类型合并为我们需要的, 参考 playground

image.png

获取构造器类型参数

ts 当中有一种类型是构造器类型,同名字的意思类似,它是提供给构造函数描述其类型的一种类型,比如如下例子type ConstructorType<T> = new (...args : any[]) => T;,在这个场景中,我们需要捕获到构造器类型的函数参数,实现其实很简单,唯一需要注意到的点是逆变,参考 playground

image.png

在上面的 case 中,与之前的例子不同,我们在限制 T 的类型时,使用了 any,而不是 unknown ,原因其实在上面的协变和逆变的部分有聊过,对于函数的参数类型,采用完全相反的规则,即逆变,允许父类型 => 子类型,所以我们这里使用 unknown 的话,下面的调用,类型守卫将会报错。

image.png

不同参数key的多结果映射(类型守卫)

如果在一个场景下,假设我们有一个函数,这个函数的参数会随着某个参数的改变而变化,例如参数 a 为 string 的时候,所有参数为形态 a, 参数 a 为 number 的时候,所以参数为形态 b。这样应该如何实现呢?

核心思路在于需要把参数之间产生联系, ts 的类型守卫只会对同一个对象或者元组进行监控,如果拆分开来用泛型限制,只会在参数最初始的时候进行关联,后续限制类型就不会再进一步类型推导,参考 playground

image.png

修改对象的 key 都变为大写

之前我们有介绍过,映射类型应该如何遍历 key,在这个场景下,我们不仅需要遍历 key 我们还需要改变它的值,在 ts 中需要改变 key 的类型,我们只需要使用 as (断言) 即可,参考 playground

image.png

在上面的例子中,我们还使用了官方提供给我们的内置高级类型 Uppercase, 它可以将类型变成大写形式,有同学可能会疑问,Key & string 是干嘛的呢,因为类型守卫解析出来的 key 包含 string | number | symbol 三种类型,所以 & string 相当于是一个兜底,也就是告诉类型守卫,我会保证它是 string

移除readonly

这道题相信难不倒大家了,知识点我们大部分都提到了,只需要遍历映射类型即可,需要额外说明的是,移除 readonly 我们只需要写 - 即可,参考 playground

image.png

移除可选修饰符

这道题和上面那道题思路相同,不过因为 ?: 的位置不同,担心有些同学还不能灵活举一反三,所以我想还是有必要单独练习一下的,参考 playground

image.png

获取多层 promise 的最终返回值

这道题算是递归的一道经典题了,我们需要不停地提取 promise 的返回值,直到返回值不为 promise 类型后再返回,大家可以参考 playground

image.png

反转数组

这道题同样需要使用到递归,每次我们反转一个元素和剩下的数组序列,然后对剩下的数组序列执行同样的操作即可,参考 playground

image.png

小结

到这里我们 TypeScript 类型编程的知识就大致讲完了,真正要熟练掌握还需要勤加练习,不得不提的是,在前端方向的确存在大量重复轮子或者无意义八股文的现状,这些不可否认都是为了筛选出定向人选而产生的卷。

但是对于 TypeScript,它是一个很有价值,甚至能对 javaScript 应用于大型项目中缺陷进行有效弥补的超类。如果遇到阻塞的类型就使用 any,项目的劣化程度只会越来越严重,甚至 typeScript 也会形同虚设。好好地学习一下 typeScript 和类型编程是有意义的,而且可以真正提高大家的代码质量,希望大家可以重视这个部分。有问题的同学也欢迎在评论区中留言交流~