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

折腾是进步的阶梯

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

目 录CONTENT

文章目录

typescript入门笔记(三):类型兼容

lumozx
2023-04-07 / 0 评论 / 0 点赞 / 21 阅读 / 24931 字

注:本文typescript版本为5.0.2

我们已经基本了解了类型,但我们也只是知道,一个数据,应该有一个类型,但多个数据有着不同的类型,他们应该有着怎样的交互呢?同时,不同类型相互兼容是有着怎样的逻辑呢?

鸭子类型

我们应该听过一种说法,如果你看到一只鸟走起来像鸭子,游泳像鸭子,叫声也像鸭子,那么这只鸟就是鸭子。将这部分思维扩展到typescript上,就是结构化类型系统。用代码来举例的话,我们会看到如下代码。

这串代码并不会报错,因为他们符合结构化类型系统:如果入参是一个对象,并且是一个空对象,这个空对象的原型两有且只有eat方法,那么他就是Cat类型,即使实际上这个参数是Dog类实例出来的。

如果我们对代码进行一些改动,就会产生报错

注意到,我们给Cat增加了miao方法,而new Dog产生的实例并没有miao方法,从而使得只接收Cat类型参数的feedCat函数在进行类型检查的时候,产生了报错。

那么,如果我们给Dog增加方法呢?

结果不会报错了,这有违背我们之前的推论!我们之前的推论说,如果一个鸟符合所记载鸭子特征,那么就是鸭子,可是这个鸟却在完全符合特征的基础上,变异出来了新的功能——它会后空翻!实际上,typescript不会报错也是有原因的,首先:一只后空翻的鸭子可以认定为鸭子的子类,后空翻就是继承父类之后,子类新增的方法,结合上文的代码,feedCat进行类型检查的时候,会把传进来的参数认定为一只猫,只不过这只猫会汪汪叫。

那么,如果他们都有一个相同的方法,但是方法类型不同,会不会报错呢?

答案是会报错,也就是说,类型检查在比较对象属性的时候,不光会检查属性的有无,也会检查属性的类型相不相同。

在这里,Cat和Dog是没有关联的类型,如果存在父子继承关系的话,这种情况就会被称为逆变。


这里补充一点逆变和协变的知识。逆变和协变解释起来很简单。

如果存在类,继承的子类即使增加了新的方法,也可以赋值给父类型,这个叫做协变,也就是说子类也是父类。

如果存在函数,在不考虑返回值类型的前提下,入参是父类,是可以赋值给经过扩充的子类,这个叫做逆变,也就是说父类也是子类,返回值依然是协变。

通俗来讲,参数类型允许为当前参数的父类型,不允许当前参数的子类型,这是逆变,返回值运行为当前返回值的子类型,不允许当前返回值的父类型,这是协变。

当然,这种可能会产生矛盾的结果,因此在typescript中引入了双变的概念,也就是子类父类都可以。

比如,B是A的子类,B[]能否赋值给A[]呢?答案是肯定的,但是如果我们用逆变和协变的思路来看看呢?

  • 数组里面有push方法,也就是说B[].push 能否赋值给A[].push

  • 按照push类型可以看做(B) => void 是否可以赋值给(A) => void

  • 按照上面的推断,因为有逆变,子类是不允许给A类型的,因此他们类型不兼容

  • 结论:B[]不能赋值给A[],但这个显而易见是错误的

所以我们大部分情况下,会使用双变来解决部分逆变和协变的问题,比如我们可以使用method声明来替换property声明,规避逆变的类型检查

typescript存在strictFunctionTypes配置项,用于开启逆变检查,不过这个检查只对property有效,因此这也是我们用来规避这个检查的原因。


说回正题,鸭子类型和架构化类型系统并不完全一致,后者要求我们在运行时也要进行类型检查,显而易见typescript并没有运行时,所以在typescript中,我们可以将鸭子类型看做结构化类型。C#、Python、Objective-C使用结构化类型系统。

看到这里,是不是有一种:虽然道理都懂,但Cat类型和Dog类型因为结构相同的原因,从而可以通用,还是不太对的感觉。

实际上,你的感觉是没错的,有一种类型系统叫做标称类型系统,标称类型系统要求两个可兼容的类型,名称必须是完成一致的。对于标称类型系统,父子类型只能通过显式的继承来实现。C++、Java、Rust使用这种类型系统。

那么问题来了,看起来标称类型系统比较严谨一些,typescript能否模拟呢?答案是肯定的。

还是用之前的例子,我们都知道,class里面的方法默认是public,如果是protected或者private,不同类型不管属性类型和名称是不是相同的,类型检查都会报错。同时这俩标识符是可以进行继承,也就是说符合标称类型系统的要求。

只需增加一个private或者protected属性,即可打破结构化类型的判断逻辑,拐到标称类型上来。

不过,private和protected是class特有的东西,对于

这些代码,由于是直接使用type产生了类型别名,USD和CNY具有完全相同的结构,都具有number的数据结构,但addCNY返回只是USD类型,类型检查竟然通过了。

对于这种情况,我们观察到,他们都是number类型的别名,所以我们只需要把他们变的不相同就好,因此更改number是不太现实的,那么有什么东西可以给一个类型挂载一个独一无二的数据呢?当然有,我们可以使用交叉类型,交叉类型使用&链接,当我们进行类型定义的时候,可以附带一个交互类型,来把同为number的类型别名进行区分

可以看到,我们成功区分了两个number的别名。

我们了解了 typescript 的结构化类型系统是基于类型结构进行比较的,而标称类型系统是基于类型名来进行比较的。以及在 TypeScript 中,如何通过为类型附加信息的方式,模拟标称类型系统。

从整体来讲,鸭子类型语言为程序员提供了最大的灵活性。程序员只需写最少量的代码。但是这些语言可能并不安全,会产生运行时错误。标称类型语言要求程序员显示调用类型,这意味着更多的代码和更少的灵活性(附加的依赖)。结构化类型语言提供了一种平衡,它需要编译期检查,但不需要显示声明依赖。在typescript中,不存在运行时检查,同时可以模拟标称类型,因此我们在耕耘代码的时候,可以在结构化类型和标称类型中,根据业务与逻辑寻找平衡。

类型兼容

我们一直听到一种说法,typescript任何类型都可以将any代替,也就是说any是最顶层的类型,同时也算任何类型的子类型,他兼容任何类型,我们在第一篇讲到类型相互兼容的问题,unknown是任何类型的父类型,任何类型都会赋值给unknown,但unknown不能赋值给非any和unknown的类型,因为他不是所有类型的子类型。那么,具体的类型是如何进行兼容的呢?

在类型中有一个运算符是extends ,我们可以配合三元表达式来详细检查左侧类型是否右侧类型的子类型或者当前类型。

比如

我们可以通过type类型来间接判断’string‘ 是否是string的子类型。

当然还有一个方法,那就是实际去赋值。

但是赋值有一个缺点,就是无法表达部分兼容部分不兼容的情况,这个我们接下来会遇到,因此我们先用extends来判断类型的层级关系。

原始类型

通过上面的方法,我们先来看看string和number以及boolean与字面量类型的交互

可以看到,字面量类型是可以赋值给原始类型的,同时Result6也意味着,[]的字面量类型是object的子类型。

那么万物起源Object应该是如何进行互动呢?

看起来,很明显string是String的子类型,String也是Object的子类型,同时也是{}的子类型,之前说过,{}是object字面量类型,同时也代表一个空对象,String本身也是一个大对象,根据鸭子类型,我们可以认为,String是{}的子类。也就是协变。

之后我们确认了,数组字面量也是{}的子类型。 那么除了Object、object、{}以外,其他的原始类型都是他们三者的子类型。

那么他们三个是如何进行交互的呢?

看起来他们并非线性比大小,而是绕成了一个环。实际上并非如此,造成这个现象的原因是,{} extends和extends {}中的{}并非是一个东西。{} extends是代表者object的字面量类型,也就是意味着确实是object和Object的子类型,但extends {}的时候是一个空对象,从上面的String的逻辑来看,的确是可以看做是所有类型的基类。

而13与14的情况 由于Object是万物起源,因此他的字面量与他互为父子类型。

那么他们就是所有基本类型的父类型吗,答案是否定的

可以看到undefined 、null、void他们自成一派。并非是Object的子类型。

Top type和Bottom type

那么接下来我们看看类型中的顶级类型,在typescript中,顶级类型有两个any和unkonwn。

即使是万物起源Object,也是他们的子类型,那么他们存不存在object和Object的交互呢?

我们发现,类型第一次出现1|2,这个就是我们前文说的,表达了部分兼容部分不兼容的情况,也就是说,他既是所有类型的父类型,也是所有类型的子类型。不同于object和Object的关系,any在这里不是代表了null undefined void等其他类型和{}类型的联合类型,而是any extends本身在typescript内部处理的时候,如果extends后面没有明确跟着any和unkonwn,就被处理成联合类型结果。

同为顶级类型的unkonwn表达了很合理的结果。unkonwn就是单纯的顶级类型。

那么他们两个怎么交互呢?

可以看到,他们可以相互赋值,也就是说,unknown在顶级类型中,与any是等价的。

那么谁是最底级的类型呢?

答案是never。

never甚至是字面量的子类型,他可以赋值给任何类型,因为他是万物的子类型。但遇到了any,依然是1|2,也就是说any也可以是never的子类型

联合类型和数组

处理上述的情况外,还有其他情况,比如联合类型。

联合类型我们之前提到过,只要实现联合类型中的任意类型,就可以称为实现了这个联合类型。

在29中,我们看到1 | 2 | 3是后面的 1 | 2 | 3 | 4的子集,因此是后面的子类型,但29却有点违反我们的理解,根据我们的理解,我4 | 5是一个联合类型,因此只需要实现4,即可认为是后面的子类型,这个是错误的,extends前面的联合类型,需要将每一个与后面的进行比较,若某个不属于后面,那么就判断整体不属于后面。类似于数组的some方法。换句话,联合类型的比较是看这个联合类型中的所有成员是否是在另一个联合类型中都能找到。

数组、元组与联合类型类似

  • 31中,元组内部全部为number,因此这个元组是number[]的子类型

  • 32中,类似联合类型,尽管number可以在后面找到,但string却不可以

  • 33同32,他们都能在后面找到

  • 34,[]其实是never[],nerver是number的子类型

  • 35,同34,nerver是unknown的子类型

  • 36同33,number可以在后面找到

  • 37 any是万物的子类型

  • 38,unknown是顶级类型

  • 39,never是number的子类型

我们根据这一节,了解到了一个完整的类型链。

any/unknown > Object/object > String/Number等 > string/number/null等 > 'string'/1/true等 > never > any

泛型和infer

我们在上文用了extends来判断类型的兼容,也讨论了很多情况,接下来我们来讨论一下泛型和infer应该如何进行类型兼容。

泛型

这里需要注意的是extends关键字的含义并非全等,而是前者的类型是否能赋值给后者类型,或者说后者能否兼容前者。

比如这里的B类型就可以赋值给A,这也是上文说的逆变。

而泛型就比这个复杂一些。我们看一些实例。

我们定义了一个类型,这种写法在上文之中很常见,如果前者不被后者兼容,就会抛出前者,否则抛出never。按照我们对于泛型和extends的理解,他们应该具有相同的结果,但事实并不如我们推算那样,使用泛型的时候,竟然抛出了一个联合类型,而使用联合类型的时候,抛出的值是我们预料的never。

难道是泛型并不是单纯的等量替换?

答案是肯定的。泛型是会经过内部处理遍历的。这个名称叫做分布式条件类型。

我们再看一个例子。

两个都是泛型传入的,Test1类型就是直接使用泛型,Test2使用了[]进行一层包裹,而进行数组包裹的返回了我们预料中的结果,而未经数组包裹的依然返回了一个联合类型。那么这个就直接证明了。泛型是经过内部处理,进行了遍历。官方的解释是:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上。这里的裸类型,就是指的泛型是否完全暴露。

这个看着像是BUG的东西,其实是一个特性,其实不光是使用数组进行包裹,如果使用了交叉类型,也会避免这个特性。

除了自动分发类型,还有其他特性吗?

当然也有,比如never,当我们需要判断一个类型是不是never的时候,也需要使用数组进行包裹。

这里并非是自动分发类型的特性,而是如果泛型传入的never,会直接跳过判断,返回never,类比的话就类似使用any返回联合类型一样,是一种特殊情况。不过也与any不同,就是any不止是作为泛型传入还是直接使用,都会返回联合类型。

不过这里提到了如何判断类型是never,那么如何判断是否是any呢?

我们可以用这种方法来判断是否是any,在上面的代码中0 extends 1 在常规情况下都不会成立,更何况同时还有一个交叉类型,意味着0需要符合1和T,字面量类型的交叉类型,要想成立需要传入本身、对应的原始类型,以及存在上述条件的联合类型。那么他们才会返回他们本身,否则返回never。

但有一个例外,那就是any,any的交叉类型都会返回any,即使unknown也无法返回unknown

也就是说,交叉类型中有一个是any,返回就是any,也就是说只有T是any的时候,变成了0 extends any,于是就成立了。

那么unknown呢?在测试any的时候,unknown我们发现他并不符合规律。

我们想一下,unknown extends T,当T只有any和unknown的时候才会成立,利用上文中的any判断,去掉any,剩下的就是unknown了。

infer

typescript中,存在一个infer关键字,他的意思是让typescript尝试推断一个类型,并预先给这个类型一个变量,如果真的能推断出来,那么就返回这个类型,否则这个类型的的变量失效。

我们看一下例子

在这个例子中,我们首先宽泛的规范了T的取值范围,之后用T能否推断出一个两个元素的元组,并且预先设置好他们的类型,如果真的能推断出来就交换类型的位置,反之就原样返回。这个推断是严谨的,如果是传入三个元素的元组类型,而我们退单的是两个元素的元组类型,那么推断就会失败。

同时infer还接受rest操作符,比如下面例子。

rest操作符用来操作长度不定的数组,实际上,rest操作符也不光用于infer,对于普通的类型也会适用。

不光是数组,接口也会适用infer

这里需要注意,在ReverseKeyValue中我们为了确保结果是字符串,使用了& (string | number | symbol)交叉类型和联合,来将结果收束为string、number、symbol。

如果不进行收束的话,会产生报错,因为typescript中,对键值对类型进行infer推导,会导致类型信息丢失,即使在定义的时候声明T extends Record,这个string也会被丢失,从而不满足索引类型。

当然,infer也支持约束,比如

如果推导出来的类型不符合我们给予的范围,那么会认为推断失败,从而使用三元表达式的后面的计算式。

void和参数类型

我们在上文了解到了逆变和协变,也了解到了类型的兼容,但有的时候,两个看起来很合理的逻辑,在实际操作上却有点反直觉,比如void和参数类型。

我们来看一个例子。

这个例子看一眼感觉是有问题的,但实际上,这个例子是十分正确的,即使返回值的类型是void依然可以使用其他值,也即使参数个数不符合类型要求。

对于返回值是void类型的函数,并不会真的要求返回值的类型是void,因为存在箭头函数的原因,很多情况下我们会将箭头函数的函数体写入返回值中,如果要求为void的话,那么就需要在外层进行包裹,这样会大大束缚箭头函数的灵活性。当然,在上文的函数执行的时候,他们的类型推导依然是void,即使代码逻辑上他们有返回值。

我们很多行为其实就是基于上述的“错误”逻辑运行的,也就是反逻辑符合直觉的,比如我们经常用的例子

在forEach函数的类型中,他接受一个函数,那个函数参数类型有三个,同时都不是可选参数,而返回值是void,那我们在里面使用箭头函数返回函数体的时候,也不会抛出任何错误,甚至如果没有填写任何参数,也不会抛出任何错误。

总结一下,函数返回值是void是非严格的,当返回值的类型是void的时候,并不意味你一定返回void,而是代表他的返回值名义上是void,是不需要你去关心的,返回的实际内容将被当做void类型处理,是没有处理价值的。

参数个数也是非严格的,我们实际上可以将更少参数的函数赋值给具有更多参数的函数类型。

小结

这里我们讲到了类型的兼容。并少许了解到了工具类型,这为我们之后深入工具类型起到了预习作用,同时也补充了关于逆变和协变的知识,可以说本章是前期的一个过渡章节,从了解认识类型,到深入类型,然后就是本章的类型之间的交互,之后我们将把类型作为基础,作为一种工具,去了解越来越多关于typescript其他层次的知识。

同时说句题外话,写文章确实是一件需要坚持的事情,人是有惰性的,同时文章并非笔记,笔记是需要保证未来的自己能看懂,而文章需要保证未来的他人能看懂,因此就不得不做了一些由浅入深的努力,这么说,文章也算需要进行合理的架构的,特别是一个系列的。

未来,我们将了解更加深入的工具类型,以及typescript项目工程化,以及类型的新成员:模板字符串类型。所以这并非我们的终点,而是我们新的起点。

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