侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

typescript入门笔记(四):类型体操

lumozx
2023-06-07 / 0 评论 / 0 点赞 / 33 阅读 / 28629 字

注:本文typescript版本为5.0.2

我们的入门笔记来到了第四节,之前大多是平铺直叙的流水账,但从现在开始,我们的学习曲线将要变得陡峭起来了,不过也不用担心,我会用尽可能通俗的语言,来描述各个知识点。

模板字符串类型

我们知道js里面有模板字符串,而typescript中的模板字符串也是类似的,比如

不光如此,实际上World的类型都会被隐式转换成字符串类型。当然,也正是如此,传入的类型也会被约束为string | number | boolean | null | undefined | bigint 这几个。

模板字符串 不光支持字面量,还有更强大的灵活性,也就是说,模板字符串是用来增加字面量类型的灵活性的。

索引类型和映射类型

从上文中我们已经知道,模板字符串可以传入其他类型,但如果我想传入一组类型呢?也就是我想传入索引类型呢?

我们可以用过keyof来遍历传入目标类型,最后形成联合类型。

那么和映射类型有什么交互呢?为了与映射类型实现更好的协作, 在引入模板字符串类型时支持了一个叫做 重映射(Remapping) 的新语法,基于模板字符串类型与重映射,我们可以实现一个此前无法想象的新功能:在映射键名时基于原键名做修改

我们来看一个例子,首先我们需要复制一个接口,然后将接口的名字上加入固定字符串。

上面是是一个复制接口的类型,如果想修改键名,需要使用as语法。

需要注意的是,由于对象的合法键名类型包括了 symbol,而模板字符串类型插槽中并不支持 symbol 类型。因此我们使用 string & K 来确保了最终交由模板插槽的值,一定会是合法的 string 类型。

专用工具类型

既然是字符串,那么字符串大写、小写等应该是基本操作,那么typescript能否处理字符串类型呢?答案是肯定的。

typescript内置了Uppercase、Lowercase、Capitalize 与 Uncapitalize,看名字就能知道它们的作用:字符串大写、字符串小写、首字母大写与首字母小写。

他们可以将字符串进行转译为目标字符串,这里需要注意的,由于一般操作是无法达到目的,他们的实现方式与一般的type不同,而是内置实时js进行转换,换句话说,他们是调用了js的函数进行实时转译的,而非通过类型编程得到结果。他们的源码是这样的。

intrinsic 代表了这一工具类型由 TypeScript 内部进行实现:

你会发现,在这里字符串字面量类型被作为一个字符串值一样进行处理,这些工具类型通过调用了字符串的 toUpperCase 等原生方法实现。

而我们使用的时候,如同正常的内置类型使用即可。

工具类型

除了typescript自身内置的工具类型, 我们自己实际上也可以实现常用的工具类型,而常用的工具类型除了一些中规中矩提取出来的类型以外,还有使用hack手段等奇技淫巧方式来达到我们目的。我们由浅入深,一步一步深入工具类型。

属性修饰

属性修饰属于较为基础的类型逻辑,但之前我们遇到的类型都是固定层级的类型,换句话说:类型如果遇到递归会怎么样?

举一个例子:我们都知道Promise会返回Promise,也可能返回非Promise,那么我们想要提取最内层的非Promise类型,那么如何做呢?

还记得我们之前学到的infer吗?我们一步一步来,先提取内部类型。

我们可以使用infer提取出V,如果成功提取到V,那么就返回V,但如果V也是一个Promise呢?虽然我们可以再写一层infer,但面对不定层数的Promise依然无法达到要求,因此,递归这个时候就起到了作用。

当能推断出V的时候,将它推入递归,再判断一次infer V,如果还能推断出来V,那么将会再次推入递归中,直到推断不出来,然后把推断不出V的那个类型返回,也就是上一个循环的结果:T。

在了解到了递归,那么将对我们接下来的学习很有用,因为属性修饰必然涉及到对象,而对象的层级就如同Promise一样,是不定的,因此针对属性的批量修改,必定需要用到递归。

因此我们可以实现以下的操作。

那么很好,我们已经学习完属性修饰一半的内容了,为什么是一半?因为上一部分虽然可以对属性进行修饰,但无法进行精准修饰,也就是说我们需要对部分属性进行修饰,这是接下来一半的内容。

在这里我们会使用官方的内置属性。假如,我们有一个对象类型,我们需要将其中一个属性变为可选属性,那么我们需要将目标类型传入我们的工具类型中,最好指出哪些属性需要变为可选类型。

那么MarkPropsAsOptional应该是什么样子的呢?我们来理一下思路。typescript存在内置Omit内置类型,也就是排除部分属性,剩下的对象类型,那么我们就用内置Pick将传入的属性单独独立成对象类型,然后使用Partial把这个对象都变为可选类型,最后使用Omit组成交叉类型,因此我们就实现了需求。

如同上文说的,Pick其实就是{bar: number},然后使用Partial变成了{bar?: number },而后面的Omit,是剔除bar之后的对象,也就是{ foo: string; baz: boolean; },他们的交叉类型就是{bar?: number } & { foo: string; baz: boolean; }。

也就是说MarkPropsAsOptionalStruct 类型就是{ foo: string; bar?: number; baz: boolean;

在上文中,我们使用了内置工具类型Partial,也可以换成我们的DeepPartial,来进行深度遍历。

那么进行举一反三,我们可以将其他的也进行如此变化。

模式匹配

我们之前学infer的时候,提到过可以来交换元组、提取接口,这个就叫做模式匹配,实际上,模式匹配不仅仅做到如此,比如我想要最后函数类型的最后一个参数类型。

我们可以使用infer匹配出参数,然后通过rest匹配出最后一个参数。

模板字符串类型匹配

首先有一个需求,将一个人名前后互换,然后首字母都大写。

比如'zi xiao' 换成'Xiao zi'

我们来分析一下原字符串的特点,首先他们由两组单词组成,然后他们以空格分割。

那么使用之前infer逻辑,再加上之前提到的内置类型,就可以得到一下类型。

但这样有个问题,如果名字是三个单词的话,我们的匹配模式就失效了,因为我们只考虑到两个单词的情况。如果多个单词,但都是空格分割的呢?其实解决方法与Promise类似,就是递归。

在上述代码中,First是真正的第一个单词,但后面Last 其实是xiao am i ,然后递归,递归中的First是xiao 然后Last是am i,由于还有空格,我们还能继续递归,知道最后i没法递归,我们将他转成大写,然后得出结论。

那么发散一下,如果不是空格分割呢?比如字符串是'zi~xiao~am~i',然后我们通过泛型传'~',来实现自定义分割,而结果使用空格分割?

也很简单,在空格的地方将分割符带入进去就可以了。

那么我们来进阶一下,我们在业务中,经常用到id数组转ids字符串,也就是数组转字符串用逗号分割,比如:

那么怎么用上文学到的类型进行编程呢,首先确定一下,原数组的类型是number,也就是number[],逗号分割是确定的,因此可以写死在逻辑里面,因为长度不确定,因此使用递归。

思路有了之后,我们就能写出以下工具类型。

在开始的时候,就规避数组为空的情况,如果空数组就返回空字符串,然后inferA和B的时候,因为后面的模板字符串只接受string | number | boolean | null | undefined | bigint ,因此我们需要extends约束为number,因为rest的特性,B总会被推断出来,但结果可能是空数组,也就是[],所以我们需要判断B是不是空数组,如果是的话,就代表A是最后一个,就不加逗号了,同时因为最开始空数组直接返回字符串,所以递归中的JoinStr也是空字符串,从而结束递归。

数组类型匹配

我们之前在学习infer的时候举的例子其实就是数组类型匹配,让我们来复习并且进阶一下,如果我们把数组类型去掉第一个元素,也就是数组中的shift操作,如何进行类型编程呢?

这里需要注意的是,如果不判断Arr extends [] ? []的话,传入的空数组返回的是never,需要将最后never改为[]

也就是

不过还是推荐对一种方法,进行双层兜底,第二种方法只有一层兜底,同时未知类型数组建议使用unknown,而不是any,因为之前分析过,unknown是顶层类型,所有类型可以赋值给它,但any既是顶层类型,又是底层类型,因此unknown更加收敛一些。

构造器类型匹配

我们在前面讲过了函数的类型匹配,那么我们来看一下构造器的类型匹配,他们区别是构造器用于创建对象,可以被new,构造器也能使用接口来定义

那么如果我们使用模式匹配,如何匹配出Person呢?从这里可以看到,new在接口中使用了方法一样的定义,Person就是方法的返回值,因此我们可以直接使用函数的模式匹配,将Person匹配出来,不过需要注意,因为这个是构造器,因此我们需要加入构造器的类型约束。

我们在类型约束,和infer推断中都加入了 extends new (...args: any) => any,来确保我们传入的是构造器,进行推断的时候也是根据构造器的逻辑来进行推断的。

索引类型匹配

我们想匹配出索引类型某个字段的类型,这种索引类型匹配有简单的方法,也就复杂的方法,复杂的方法就是通过模式匹配,将目标的索引推入infer判断,来确定字段的类型

需要注意的是,我们这里需要加'ref' extends keyof Props判断,因为ts3.0如果没有对应索引,返回的是 {} 而不是 never,所以这样做下兼容处理。

当然,还有更加简单方法

写法不唯一,有很高的自由度。

那么发散起来,如果中括号传入的是keyof呢?那么返回的将会是所有属性的联合类型。

特性深入

上文中我们讲了很多特性,包括递归、类型匹配,那么还有其他特性吗?答案是有的,甚至有的特性可以说是hack。

数组计数

类型编程里面是没有运算符号的,最多是三元表达式,但如果我们想实现加减乘除呢?实际上,相关知识在上文中我们都已经学习过了,只是我们在这里将他们组合起来。在上文中,我们看到元组是可以相互组合的,成为一个新的元组类型,同时我们可以通过索引类型匹配,将length匹配出来,根据这个特性,我们可以实现计数功能。

这里需要注意一点,虽然这里提到了数组和元组,他们的length其实是不同的

因此,在这里说的数组,在其实都是元素个数确定的数组,也就是元组。

还需要注意一点,如果我们想取类似索引类型的所有属性的联合类型,并不能使用keyof,而是number,因为元组和数组的索引都是number

上文的逻辑已经很清楚了,我们需要构造多个数组,然后将他们合并成一个新的数组。

那么问题来了,我们如何构建不定长度的数组呢?在正常编程中,我们很容易做到,但是别忘了,这个是类型编程,没有Array等内置方法,不过,我们有递归啊。

虽然这支持三个泛型,但实际上有两个泛型具有默认值,且内部消化了,在使用的时候,只需要第一个泛型即可,这个类型首先判断了当前数组的length有没有达到目标长度,如果没有,就往里面塞一个unknown,然后递归,直到长度符合,返回结论数组。

既然构建数组的方法出来了,那么我们的加法类型就水到渠成了。

如同上文的思路,构建两个数组,然后取他们的length,就得到了加法的结果。

这里需要注意的是Arr['length'] extends Length 不能写成Length extends Arr['length'] ,这样会报错,因为虽然我们知道Length是一个具体数,但类型检查的时候Length的来源是Num1,而Num1的约束是number, 因此在检查的时候Length的类型number,而Arr['length'] 目前是一个具体数:0,number extends 0永远不成立,走到BuildArray,因此在检查的时候就无限递归,从而报错了。

这里需要是注意一点,基于数组长度的加减乘除是不能支持负数的,结果也是一样,因此,减法我们就默认大的数减去小的数。

根据上文的思路,其实就是构建一个长度固定的数组,然后再构建第二个长度固定的数组,通过模式匹配,使用infer Rest匹配出剩下的元素组成的数组,这个数组的长度就是结果。我们直接上代码

如果大数减小数来说,这个类型是实现了我们的需求了。

上文我们都是通过数组长度来运算的,但乘法呢?数组可不支持乘法啊。别急,众所周知,乘法是反复利用的加法,比如 2 * 3,实际上是2 + 2 + 2,也就是 2加他自己3次,也就是重复加法递归3次。

这个跟加法一样,只不过多了一个结果状态,还是数组与数组组合,得到新的数组,只不过组合数组是只有Num1参与,然后进行递归,每递归一次,Num2就减1,如果Num2归零,那么就发结果状态的长度返回回来。

好吧,我们来约定一下,由于数组的限制,触发只能接受整除,小数什么的,数组长度目前实现不了。

根据乘法的思路,除法其实是前面的数减去后面的数,看能减几次成为0。因此还是引入计数机制,不过观察为0的不是Num2,而是Num1了

[unknown, ...CountArr] 实际就是看能减几次,每减一次,就会加入一个unknown,最后CountArr的长度,就是我们想要的结果值。

比大小

类型编程是没有识数能力的,那么我们怎么让他们比大小呢?其实还是结合之前数组的能力,我们往一个数组类型中不断放入元素取长度,如果先到了 A,那就是 B 大,否则是 A 大。

两个数进行比较,如果结果数组长度达到了某个数,那就是另一个数大,如果都没达到,就让结果数组长度加1,然后递归比较。

特殊特性

分发特性和模板字符串

我们都知道,如果泛型传入extends左边,会触发分发特性,其实,除了extends,我们还可以主动触发触发分发特性——只要模类型中的类型是联合类型,就会触发分发特性。比如我们经常用的BEM,我们知道class中有BEM这种结构,如果把他具体到类型上呢?

可以看到,Element被分发了,结果转为了联合类型。

IsEqual

一般来说,IsEqual是相互extends,如果成立,说明这两个类型是相等的,但如果某个值是any,这个判断是就不好使了。

因为 any 可以是任何类型,任何类型也都是 any,所以当这样写判断不出 any 类型来。

那么我们应该怎么样呢?这个就需要hack了,我们可以通过typescript的源码得知。

source 和 target 都是条件类型(Conditional Type)的时候会走到这里,这里有一句注释,如果是两个条件类型 T1 extends U1 ? X1 : Y1 和 T2 extends U2 ? X2 : Y2 相关的话,那 T1 和 T2 相关、X1 和 X2 相关、Y1 和 Y2 相关,而 U1 和 U2 相等。需要注意的是,这里有两个名词,相关和相等,相关就是extends来判断的,比如1 extends number,说明1和number是相关的,但他们并不相等。

不过,我们可以利用这个特性,如果我们构建一个条件类型,然后吧T1和T2设置为相关,X1和X2 设置为相关,Y1和Y2设置为相关,然后把比较的类型分别放到U1和U2,如果他们还相关,说明U1和U2是相等的,反之是不相等的。

这里面的T是为了触发条件类型,无实际意义,并且都是T,做到了相关,1、2也进行了相关,只有A、B是未知的,如果整个类型计算是相关的,说明A等于B,反之不等于。

联合类型变为交叉类型

我们都知道,类型是有父子关系的,更具体的是子类型,更宽泛的是父类型,比如,A和B的交叉类型,就是A和B的联合类型的子类型,前面的文章我们还讲过逆变和协变,允许父类型赋值给字类型叫做逆变,反之是协变,我们之前也讲过,函数参数其实就是逆变,也就是说,如果参数可能有多个类型,那么infer参数类型就会变成交叉类型。因为infer会推断会倾向于保守、精确的方面,因此我们可以利用这一点。

U extends U 是为了触发联合类型的分发性质,让每个类型单独传入做计算,最后合并。当前函数的参数可能是多个类型,也就是联合类型的时候,infer就去趋于保守,推断出来他们的交叉类型,而我们要的就是这个交叉类型,其他其实并不重要,因此我们可以通过这个特性,实现联合类型变为交叉类型。

提取可选索引

过滤可选索引,就要构造一个新的索引类型,过程中做过滤

大部分是中规中矩的逻辑,主要的过滤逻辑是as {},就是单独取出一个索引所构造的对象,判断空对象是是不是当前对象的子类型,如果是可选索引,那么空对象就是他的子类型,反之就不是。

这里需要注意的是,可选索引不等于undefined类型。也就是说,如果那个索引的类型是undefined,那么空对象依然不是他的子类型。只有加上问号的,才成为可选索引。

提取public

如何过滤出 class 的 public 的属性呢? 也同样是根据它的特性:keyof 只能拿到 class 的 public 索引,private 和 protected 的索引会被忽略。 比如这样一个 class:

所以,我们就可以根据这个特性实现 public 索引的过滤:

类型参数 Obj 为待处理的索引类型,类和对象都是索引类型,约束为 Record。 构造新的索引类型,索引是 keyof Obj 过滤出的索引,也就是 public 的索引。 值保持不变,依然是 Obj[Key]。

as const

typeScript 默认推导出来的类型并不是字面量类型。

如果我们用as const的话,之后推导出来的类型是带有 readonly 修饰的,所以再通过模式匹配提取类型的时候也要加上 readonly 的修饰才行。

比如我们需要模式匹配出第一个元素,我们需要加上readonly才可以。

小结

这里我们讲到了类型体操,虽然我们铺垫了很多节,但并没有我们想象中那么难,其实本身学习并没有那么陡峭,而是我们如何去用,当我们下意识去用了类型体操中的技巧的时候,那时候我们才真正学会了,到这里,我们的typescript学习之路就快结束了,这并非我们的终点,而是我们新的起点。

0
博主关闭了所有页面的评论