🍀 函数与泛型
1. 函数声明与调用
函数我们之前一直在使用了,基本上和Javascript声明函数是一样的,只是在Typescript中我们需要现实的注解函数的参数
1 | function add(a: number, b: number): number{ |
函数的返回类型Typescript可以自己推导出来,不过也可以显示的注解
当然对于函数的声明也和Javascript一样,无论是具名函数,还是函数表达式,还是箭头函数,都没有任何问题
1 | function sayHello1(name:string) { |
在函数调用的时候,我们就无需提供任何额外的类型信息了,直接传入实参即可,Typescript将检查实参是否与函数的形参类型兼容
1 | const sum = add(1, 2); |
返回的结果也不需要你纠结,类型是固定的。
如果忘记传入某个参数,或者传入的参数类型有误,Typescript将指出问题
1 | const sum = add(1); //error 应有2个参数,但获得1个 |
1.1 可选参数与默认参数
同样可以使用可选符号?
把参数标记为可选。
声明函数的参数时,必要的参数放在前面,然后才是可选参数
1 | function sendMessage(userId: number, message?: string) { |
与在Javascript中一样,可以为可选参数提供默认值,这样做在语义上与把参数标记为可选是一样的。
带默认值的参数调用时可以不用传入参数的值,并且带默认值的参数不要求一定要放在参数列表的末尾,可选参数是必须放在末尾。不过默认参数放在前面也没有任何意义,约定俗成我们都还是放在末尾。
1 | function sendMessage(userId: number, message="hello") { |
1.2 剩余参数
函数有时候接受参数的数量是不定的,如果不想让参数固定,在以前,我们可以使用arguments这个隐藏参数搞定
1 | function sum() { |
使用arguments的坏处显而易见,就算我们之前写Javascript,你也不会使用arguments去处理这些问题。在Typescript更加如此。
- arguments是一个伪数组,使用的时候还需要转换才能使用数组相关方法
- 在调用的时候,会明确的提醒你,这个函数不需要参数,但是你却给了参数
- 还有一个隐藏的点是,reduce函数调用的回调函数中,所有的参数全是any
要确保安全可靠,我们当然应该使用剩余参数(rest parameter)
1 | function sum(...numbers:number[]) { |
使用剩余参数,上面所有的问题都解决了:
- 剩余参数是一个数组,并且我们可以使用Typescript定义具体的类型
- 调用函数的时候,也会有明确的提示
- reduce回调函数中的参数全部都有具体的类型
当然,唯一需要注意一点是:
一个函数最多只能有一个剩余参数,而且必须位于参数列表的最后
1.3 this的类型注解
在Javascript中,我们可能会写出下面的代码(可以在浏览器上测试)
1 | function showDate() { |
这个函数里面的this
,其实很明显是为了获取Date
对象,所以,我们调用的时候,会使用call
方法绑定具体的Date对象
1 | function showDate() { |
如果打开了
"strict":true
,默认也会开启"noImplicitThis": true
,没有显示的指定this的类型也会提示错误,在一般的函数中,如果要使用this
,就必须显示的标注this
的类型注意:
noImplicitThis
不强制要求类和对象的函数必须要注解this
当然,这种写法本身就是很危险的,谁也不知道这个showDate
方法应该如何去调用,不过,在Typescript
中如果出现了这种写法,我们可以使用this
的类型注解,确保需要传入对应的this对象
1 | function showDate(this: Date) { |
如果函数使用
this
,可以在函数的第一个参数中声明this
类型(放在其他参数之前),这样在调用函数的时候,Typescript将确保this
是你预期的类型
this
不是常规的参数,而是保留字,是函数签名的一部分
"strictBindCallApply": true
开启这个配置选项能比较安全的调用.call
、.apply
和.bind
,会检查传入的参数是否和this
匹配,当然strictBindCallApply
配置也是属于strict
家族的一员
2. 调用签名
我们现在在讲函数,那么我们声明的函数到底是什么类型呢?就是一个Function
类型吗?
其实,和我们之前讲的对象类型一样,object能描述所有对象,function也可以描述所有函数,但是并不能体现函数的具体类型
1 | function add(a: number, b: number) { |
像上面这样的函数,我们可以用Typescript进行描述
1 | (a: number, b: number) => number |
这是Typescript表示函数类型的句法,也称为调用签名。
函数的调用签名只包含类型层面的代码,即只有类型,没有值。因此,函数的调用签名可以表示参数的类型、this的类型、返回值的类型。剩余参数的类型和可选参数的类型,但是无法表示默认值(因为默认值是值,不是类型)。调用签名没有函数体,无法推导出返回类型,所以必须显式的注解
函数签名其实对我们写函数也有指导意义。
1 | type Greet = (name: string) => string; |
我们可以把调用签名和函数表达式结合起来
1 | type Log = (userId: number, message?: string) => void; |
将函数表达式注解为Log类型后,你会发现,不必再此注解参数的类型,因为在定义Log类型的时候已经注解了message和userId的类型。Typescript能从Log中推导出来,同理,返回值类型其实也是一样(当然返回值类型本身就能帮我们进行推导)
上面使用的都是类型别名,当然也能使用interface
1 | interface Log { |
其实,如果我们已经有具体的函数了,完全可以通过typeof
获取函数的类型声明
1 | function greet(name: string) { return name } |
如果是有回调函数,一样添加回调函数相关声明就行了
1 | function handleData(data: string, callback:(err: Error | null, result: string) => void):void { |
当然可以把callback
的声明再提取成一个类型别名
1 | // 注意:没有用模块化,类型别名的命名容易和全局变量冲突, 比如ErrorCallback |
当然也能够整个函数提取为类型别名或者接口,交给函数表达式处理:
1 | type ErrorCB = (err: Error | null, result: string) => void; |
当然了,也能使用typeof直接获取已有函数的类型声明:
1 | type HandleData = typeof handleData; |
在对象字面量类型中声明函数类型,和普通的函数类型声明没有什么差别:
1 | type User = { |
2.1 上下文类型推导
直接把函数类型进行声明,Typescript能从上下文中推导出参数的类型。这是Typescript类型推导的一个强大特性,我们一般称为上下文类型推导。
上下文类型推导,在某些时候非常的有用,比如回调函数中
1 | function times(fn: (index: number) => void, n: number) { |
上面的函数times的回调函数fn,强调需要一个number类型的参数,并且没有返回类型。
当我在调用times函数的时候,当然需要传入这个一个回调函数(n:number) => console.log(n)
按照正常情况,既然是一个函数,那么函数中传递的参数,就应该声明类型。
但是,这里其实我们可以省略,直接简写为:
1 | times(n => console.log(n), 4); |
因为Typescript能从上下文推导出n是一个数字,因为在times的签名中,我们声明回调函数f的参数index是一个数字。那么Typescript就能推导出,下面传入的回调函数中的参数n,就是那个参数,该参数的类型必然应该是number类型
但是,也有需要注意的地方:
如果回调函数的声明不是在行内直接声明,那么Typescript无法推导出它的类型
1 | const fn = (n) => console.log(n); // error 参数n隐式具有“any”类型 |
这个错误当然也很好理解,外部直接声明的fn相当于是一个全新的函数了,在声明的时候和times函数是没有任何关联的,当然不可能进行上下文类型推导
3. 重载
在某些逻辑较复杂的情况下,函数可能有多组入参类型和返回值类型.
比如有这样简单的需求:函数有两个参数,要求两个参数如果都是number类型,那么就做乘法操作,返回计算结果,如果两个参数都是字符串,就做字符串拼接,并返回字符串拼接结果。其他情况直接抛出异常:参数类型必须相同
首先,很多初学者的直观想法是,我直接声明两个不同的函数不就完了。
首先这种做法不是我们要讲的这个概念。另外,其实我们做的事情是一样的,比如
console.log()
函数,我们可以传递number,string,boolean甚至对象,都能实现打印,但是使用用到的都是一个console.log()
函数,如果不同的参数,对应不同的函数名,那这样对于使用者来说,是极大的心智负担。
根据这样的做法,我们很容易写出下面的代码:
1 | function combine(a: number | string, b: number | string){ |
这个代码,咋看没有任何问题,但实际上隐含了很多问题在里面。
第一个,这样的代码实际并没有起到类型约束的效果,我们要类型系统,目的就是要在编译期间就帮我们提示错误,避免运行时错误,然后再回来调试。而现在这个代码的问题:
- 参数可以输
number
也可以输入string
,并没有在编译时就给我提示不能输入不同类型的参数 - 返回值类型并不固定,两个参数是
number
,那么返回的类型,就应该一定是number
,但是现在返回的是string | number
第二个,是类型编程语言的常识问题:在很多静态语言中,一旦指定了特定的参数和返回类型,就只能使用相应的参数调用函数,而且返回值的类型始终如一。而我们已经习惯了Javascript的写法,了解了一点点Typescript语法,就觉得上面应该是没问题的啊,有类型的限定,有函数的自动推导。
其实,在很多静态语言中,上面的写法根本不成立,要么参数指定是数值类型,要么参数固定是字符串类型,也没有所谓的推导,函数返回值类型也必须指定,比如下面的伪代码:
1 | function combine(a:number, b:number):number{ |
声明函数的时候就固定好,这样省去了判断的麻烦。
所以,简单来说,其实Typescript对比其他静态编程语言,还是具有一定的动态性,函数的输出类型取决于输入类型的推导。你可以把这个理解为Typescript是更先进的类型系统…也可以理解为是为了兼容Javascript的动态性不得已而为之。
基于这个问题,我们可以使用**函数重载签名(Overload Signature)**来解决这个问题
1 | function combine(a: number, b: number): number; |
这里我们的三个 function combine
其实具有不同的意义:
function combine(a: number, b: number): number;
,重载签名一,传入 a和b 的值为 number 时,函数返回值类型为 number 。function combine(a: string, b: string): string;
,重载签名二,传入 a和b 的值为 string 时,函数返回值类型为 string 。function combine(a: number | string, b: number | string)
,函数的实现签名,会包含重载签名的所有可能情况
注意:重载签名和实现签名必须放在一起,中间不能插入其他的内容
继续看下面的例子,根据函数传递的参数,如果传入string
类型,就转换为10进制的number
类型,如果传入的是number
类型或者其他类型,就调用toString()
转换为string
类型
1 | function changeType(x: string | number): number | string { |
这样写代码依然和之前的问题一样,不能在编译时提供帮助,因此加上重载签名
1 | function changeType(x: string): number; |
不过在声明重载的时候,还是有一些细节需要注意,比如,我们模拟DOM API
中createElement
函数的处理,这个函数大家都用过,参数传递具体的标签名字符串,就帮我们创建对应的HTML元素
1 | function createElement(tag: "a"): HTMLAnchorElement; |
由于重载签名只有a | canvas | table
的情况,因此,如果调用函数的时候,传入不是这三个类型的字符串就会报错,其实我们可以再加入一个兜底的重载签名,如果用户传入自定义标签名,或者一些前沿性的标签名,我们直接返回一般性的HTMLElement
1 | function createElement(tag: "a"): HTMLAnchorElement; |
需要注意的是:拥有多个重载声明的函数在被调用时,是按照重载的声明顺序往下查找的,简单来说,特殊的子类型,比如类型字面量等我们放在上面,兜底的类型,我们应该放在最后,如果你讲兜底的类型放在的最上面,无论如果,函数签名找到的都是第一个
实际上,
TypeScript
中的重载是伪重载,它只有一个具体实现,其重载体现在方法调用的签名上而非具体实现上。而在如Java
等语言中,重载体现在多个名称一致但入参不同的函数实现上,这才是更广义上的函数重载。
4. 理解泛型
前面讲解了这么多,突然有一天有一个很简单的需求摆在你的面前,让你用Typescript封装一个函数,传入任意类型的参数,传入什么类型就返回什么类型…
对于Javascript来说,这有什么难度?甚至用箭头函数写更加简单
1 | function identity(value){ |
但是对于Typescript来说,就有问题了,参数要有约束,返回的类型也应该和参数是同一个类型,那这该怎么办?
难道传入number
?传入string
?
1 | function identity(value:number){ |
但是这样不是限定死了,就只能是参数限定的类型吗?
既然要任何类型,首先你会想到用any
1 | function identity(value:any){ |
但是用了any
和直接写Javascript有什么区别呢?这就丧失了类型检查的效果。就算现在不报错了,最后得到的变量其实也不知道到底是什么类型,比如上面代码中的变量,并没有调用字符串的相关方法的提示。这就完全丧失了使用Typescript的意义。
当然这个时候,我们就会想到前面刚刚讲的函数重载
1 | function identity(value: number):number; |
这么写貌似没问题了,但是仅仅就只有3个基本类型啊,如果还有不同类型的数组呢?不同类型的数组都还好说,无非就继续往上加类型而已
1 | function identity(value: number):number; |
但是如果还有对象类型呢?
1 | function identity(value: number):number; |
对象类型object会报错,为什么?这在我们之前就解释过,object仅仅表示对象而已,并不知道对象里面具体有什么,那我们就只能使用对象字面量…那这就完全没戏了。谁知道对象字面量里面有多少的内容呢?
那么这里好像就没有更好的办法可以解决了?
如果能利用Typescript的自动推导的功能,我不确定当前用什么类型,当用到什么类型的参数的时候,根据我传给你的类型进行推导,只要在我代码用到了推导的类型,那么就都是这个类型,这就是我们要讲的泛型
1 | function identity<T>(value: T): T{ |
在函数名后,使用尖括号<>
来声明泛型T
,表示传递的泛型的类型,你可以把他先理解为一种占位符号。后面凡是出现一样的这个符号T
,那就表示是一样的类型。
本质上T
其实和我们写的number,string等等是一个意思。当我们调用时:
1 | function identity<T>(value: T): T{ |
当我们调用的时候,TS其实可以根据我们传入的参数自动推导泛型的类型,所以,调用的时候,前面的<>
是可以省略的。
1 | const s1 = identity(1) |
那为什么是T
?
T
就是一个类型名称,如果愿意,可以使用任意的其他字母名称,例如,A,B,C等等。按照惯例,经常使用单个大写字母,从
T
开始,依次使用U
,V
,W
等。就算是多个单词
Abb
,Acc
也没有问题,因为泛型字母就表示一个占位符,类型检查器将根据上下文填充具体的类型。不过一般
T
,E
,K
,V
,U
等字母用的比较多而已
再来一个例子:
1 | function getTuple<T>(a: T, b: T){ |
我们之前还写过这样的代码:
1 | function myNumberFilter(arr:number[],callback:(item:number, index?:number) => boolean):number[] { |
编译之后,你会发现myNumberFilter
,filter
这两个函数的内部逻辑是一模一样的。
这其实更进一步验证了我们之前一直讲的,类型系统和逻辑是分离的,我们在编译时把类型修改成什么样,并不会对运行时的逻辑产生任何影响。
但是,泛型让函数的功能,在编译时更具有一般性,比接受具体类型的函数更加强大。
泛型可以理解为一种约束。
当我们把函数的参数注解为fn(n:number)
的时候,参数n
的值就被约束为了number
类型。
同样,泛型T
把T
所在位置的类型约束为T
所绑定的类型
当然,上面的函数是函数声明的写法,函数表达式写法也一样:
1 | const filter = <T>(array: T[], callback: (value: T, index?: number) => boolean): T[] => { |
由于泛型本身十分的方便好用,所以在数组,对象,类型别名,以及后面要讲解的类和接口中都可以使用泛型。
只要有可能就应该使用泛型,这样写出来的代码更具有一般性,可以重复使用,并且简明扼要
5. 类型别名与接口使用泛型
数组,类型别名,类和接口中都能使用泛型
数组使用泛型:
1 | function unique<T>(array: Array<T>): T[] { |
在类型别名中当然也能使用泛型,一般简称为泛型别名。
比如我们在获取后端数据返回内容的时候,一般都是有code,message和data,code和message都好说,但是data里面到底有什么是确定不了的,如果我们希望把返回内容封装成一个类型别名,那么就需要使用泛型
1 | type ResultData<T> = { |
当然泛型别名之间也能互相调用,函数中也可以调用泛型别名
1 | type MyEvent<T> = { |
当然,接口中使用泛型和类型别名没啥区别。上面的代码直接将类型别名改为接口就直接使用了。
1 | interface MyEvent<T> { |
有时候还可以利用泛型别名给我们写TS代码带来一些方便,比如在Typescript是strict
声明下,是不能给一个已经固定类型的定义为null值的,如果我们平时写Javascript的时候习惯了定义某些值的时候给一个null,那Typescript的这种限定就稍稍觉得有那么一点不习惯
1 | type Nullable<T> = T | null | undefined; |
当然,你也可以在函数的调用签名中使用泛型,其实就是一个特定函数的类型别名嘛,比如之前的filter函数,我们可以直接用调用签名进行约束
1 | function filter<T>(arr:T[], callback:(item: T,index?:number) => boolean):T[] { |
上面是之前函数声明的写法
1 | type Filter<T> = (arr: T[], callback: (item: T, index?: number) => boolean) => T[]; |
现在写成了调用签名,相当于要直接约束某个函数的写法了,所以具体在写函数的时候,就需要传递具体的类型了
1 | type Filter<T> = (arr: T[], callback: (item: T, index?: number) => boolean) => T[]; |
不要混淆类型操作
1 | // add |
6. 多泛型
考虑一个简单问题:创建一个通用的交换函数,它接受一个包含两个元素的数组,并返回元素交换位置后的数组
1 | function swap<T, U>(pair: [T, U]): [U, T] { |
继续考虑这个一个要求,如果我们要封装一个类似于数组的map函数,简单来说,给我一个数组,然后根据回调函数,我们能组装成另外一个新的数组,而这个新的数组,可能类型和原来的数组一样,也有可能不一样
1 | // 原生map演示 |
无论怎么样,我们自己写的话,先把这个map函数实现,我们就直接函数实现就好
1 | const arr = [1, 2, 3, 4, 5]; |
可以使用多个泛型参数:
表示输入数组中元素类型的T
,以及表述输出数组中的元素类型U。
这个map函数接受的参数一个为T
类型的数组,一个为T
映射为U
的函数,最后返回一个U
类型的数组
1 | const arr = [1, 2, 3, 4, 5]; |
当然,和只有一个泛型的时候一样,在调用的时候,同样可以让Typescript自己进行类型推导
1 | const t1 = map(arr, (e) => e * 2); |
但是注意:要么就让Typescript自己进行推导,要么就写全,不能想当然的写成一半,比如都同样是number类型,你不能想当然的认为,我在调用的时候,写一个就可以了。也就是说下面的写法是错误的:
1 | const t1 = map<number>(arr, (e) => e * 2); // error 应该有2个类型参数,但只获得了1个 |
7. 泛型的默认类型
泛型的默认类型是一种编程语言中的特性,允许开发者在定义泛型时为其指定一个默认的类型。这意味着,如果在使用泛型时没有明确指定类型,将自动使用默认类型。
1 | function createArray<T = number>(length: number, value: T): T[] { |
不过这里直接给函数指定默认类型意义不大,因为函数本身就会自动的进行类型推导。
1 | type A<T = string> = { |
我们还可以使用之前的例子
1 | type MyEvent<T> = { |
之前我们声明的这个泛型别名,在用到的时候,需要声明泛型的类型。如果大部分的target都是同一个类型,那么完全可以像函数的默认参数一样,给泛型一个默认类型
1 | type MyEvent<T = HTMLElement | null> = { |
8. 受限的泛型
泛型确实给我们定义类型带来了方便,但是,有时候却显得约束力不太够,太过于宽泛。比如我们有时候会说,这里需要一个泛型T
,但是这个泛型T
就只能是一个对象,不应该是基本类型。甚至我们还想表达,这个泛型T
应该有一个必须要具备的特殊属性,比如length
或者value
。也就是说,应该为泛型设置一个上限
在ES6中,我们可以使用extends
来实现class类的继承,从而来表示某个类是另外一个类的子类,在Typescript中应用了extends的这个含义,表达了一个类型和另一个类型具有兼容性,从而起到约束泛型范围的效果
1 | function getObj<T extends object>(obj: T) { |
如果有多个泛型,同样可以:
1 | type ObjLength = { |
同样,我们可以把extends扩展到对象字面量类型上,其实extends本身就是继承的意思:
1 | type TreeNode = { |
如果上面的mapNode
函数不加以限制,T的类型根本不知道node:T
中node是否有value属性,加以限制之后,我们就很安全的在函数中使用对象node的属性value了。这其实是对类型的一种细化,或者说是对类型的守卫
有时候我们想写一个类型,比如Message
,Message
可以接受一个泛型,其主要作用是从泛型上读取泛型的 “message” 属性的类型。你可能会这么写:
1 | type Message<T> = T['message'] // error 类型“"message"”无法用于索引类型“T” |
因为泛型T
并不知道message
属性是什么,这个时候,你就可以使用泛型约束
1 | type Message<T extends { message: unknown }> = T['message'] |
8.1 元祖的类型推导
Typescript在推导元祖的类型时会放宽要求,推导出的结果会尽量的宽泛,不在乎元祖的长度和各位置的类型,其实也就是直接会推导为数组类型
1 | const a = [1, true]; // (number | boolean)[] |
然而有时候我们希望推导的结果更严格一些,把上面例子中的变量a视作固定长度的元祖而不是数组。
当然,我们可以使用类型断言as const
,把元祖标记为只读的元祖的类型
1 | const a = [1, true] as const; // readonly [1, true] |
除了使用as const
断言之外,我们还可以利用Typescript推导剩余参数类型的方式
如果仅仅写成下面这个样子:
1 | function tuple<T>(...ts: T[]) { |
得到的依然还是number类型的属性,但是如果写成下面这个样子。
1 | function tuple<T extends unknown[]>(...ts: T) { |
这个函数就完全可以替代我们声明元组的写法const tuple = [1, true];
泛型约束(T extends unknown[]
): 这里T
被约束为一个扩展自unknown[]
的类型。这意味着T
可以是unknown[]
的任何子类型,包括元组类型。
剩余参数(...ts: T
): 当使用...
操作符作为函数参数时,它在运行时表现为一个数组,但在类型层面,TypeScript能够保留传递给函数的参数类型的精确性,这包括元素的类型和数量。因此,尽管ts
在函数体内部被当作一个数组处理,TypeScript编译器仍然能够将其识别为T
类型,这里的T
是调用函数时根据传入参数推导出的具体元组类型
这意味着当你使用tuple
函数时,TypeScript编译器会根据传给函数的具体参数来推导T
的具体类型,这个类型是一个元组类型,而不仅仅是一个宽泛的数组类型
1 | const myTuple1 = tuple(1, 'hello', true); // 推导为元组类型 [number, string, boolean] |
9. 类型理解再升级-型变
我们先来看这个例子:
1 | type User = { |
答案:
deleteUser(a1); 正确
deleteUser(u1); 正确
9.1 结构化类型系统
TypeScript 的类型系统特性:结构化类型系统。TypeScript 比较两个类型并非通过类型的名称,而是比较这两个类型上实际拥有的属性与方法。User 与 Animal 类型上是一致的,所以它们虽然是两个名字不同的类型,但仍然被视为结构一致,这就是结构化类型系统的特性。你可能听过结构类型的别称鸭子类型(*Duck Typing*),这个名字来源于鸭子测试(*Duck Test*)。其核心理念是,如果你看到一只鸟走起来像鸭子,游泳像鸭子,叫得也像鸭子,那么这只鸟就是鸭子。
因此:deleteUser(a1);
正确
deleteUser(u1);
为什么也是正确的?
在很多类型系统中,都有子类型与超(父)类型的概念。当然了在java,c#这种后端名义型类型系统中子类型和父类型很容易区分,他们必须要extends
,implements
关键字。
但是在TS中,是通过结构进行区分的,不一定强制需要extends
,implements
关键字标注父子关系。比如上面的User
和AdminUser
。
明明u1
多了一个属性role
,这是因为,结构化类型系统认为 AdminUser
类型完全实现了 User
类型。至于额外的属性 role
,可以认为是 AdminUser
类型继承 User
类型后添加的新属性,即此时 AdminUser
类型可以被认为是 User
类型的子类型。
9.2 协变
在很多类型系统中,都有型变的概念,也就是类型变化的意思,在型变的系统中,
子类型可以赋值给父类型,叫做协变
父类型可以赋值给子类型,叫做逆变
之前的基础类型,我们一直在强调类型兼容性的问题,不同的类型当然没有兼容性可言,要谈兼容性,至少需要父子关系。至于所谓父子关系的兼容性,一般都具有下面的含义:
给定两个类型A和B,假设B是A的子类型,那么在需要A的地方都可以放心使用B
从上图中可以看出:
- Array是Object的子类型,需要Object的地方都可以使用Array
- Tuple是Array的子类型,需要Array的地方都可以使用Tuple
- 所有类型都是any的子类型,需要any的地方,任何类型都能用
- never是所有类型的子类型。
- 字面量类型是对应基础类型的子类型,需要基础类型的地方都能使用字面量类型
对于结构化类型,主要的型变方式就是协变。因此,
AdminUser
是User
的子类型,那么需要User
的地方,就都可以使用AdminUser
deleteUser(u1);
是正确的,不会报错。
不过这仅仅是协变的基础形态,因为对于结构比较复杂对象来说,每一个具体的属性,都有可能还是比较复杂的形态。
1 | type ExistUser = { |
新加了两种类型,注意和之前User
类型的区别主要在id
这个属性上
1 | User ---> id ---> number | undefined |
也就是说,每个类型的id属性的类型是不一样的,这里是联合类型,联合类型也有子类型和父类型的兼容关系。联合类型的父子关系的区分和基础类型是一样的。简单来说,越具体的,越形象化的,就是子类型
“hello” 字面量类型 比 string类型 更具体,那么"hello"字面量类型就是string类型的子类型
[number, number]元组类型比数组类型更具体,那么元组类型就是数组类型的子类型
a | b
联合类型 比a | b | c
联合类型更具体,那么a | b
就是a | b | c
的子类型当然,如果你不能理解上面为啥
a | b
就是a | b | c
的子类型,你可以这么想, 你妈你去市场买水果,买梨子 | 苹果
肯定比买梨子 | 苹果 | 西瓜
更具体
因此,就id
这一个属性来说:
1 | ExistUser < User < LegacyUser |
由于另外一个属性是一样的,所以:
1 | ExistUser < User < LegacyUser |
那么我们就可以得出结论
1 | deleteUser(u2); 正确 |
typescript对于结构(对象和类)的属性类型进行了协变,也就是说,如果想保证A对象可赋值给B对象,那么A对象的每个属性都必须是B对象对应属性的子类型。
如果A是B的子类型,那么我们可以说由A组成的复合类型(例如数组和泛型)也是B组成相应复合类型的子类型
1 | type Pet = { |
10. 多余属性检查
正是由于有协变这个特性,有时候又会给我们写代码增加一些困惑。
1 | let u4: User = { |
如果是函数中也是一样:
1 | deleteUser(u1); // 正确 |
由于有协变,我们可以把AdminUser
类型的u1对象看做是User
类型的子类型,然后我们可以赋值。
但是如果我们直接赋值,如果多写了属性,提示错误,这个我们也应该能理解,标注了是User
类型,但是你却多写了其他属性,TS当然应该帮我们提示错误才对。而且对于这种和标注类型不匹配的检查,在TS中就叫做多余属性检查。
只要将一个直接字面量赋值给变量、方法参数或者构造函数参数,就会触发多余属性检查
当然,就算直接赋值一个字面量对象,你能自己确定是什么类型,直接用类型断言处理一下也可以。
1 | deleteUser({ id: 2, name: 'user2', role: 'admin' } as User); |
11. class也是结构化类型
先来看一下讲过的协变内容:
1 | type Animal = { |
Animal、Pet和Dog,很明显具有逐层的父子关系
1 | let a: Animal = { |
顺便提一句,使用class类也是一样的,关于类的基本使用,和Javascript是一样的。而且父子层级关系也是一样。关于类型的一些问题,我们在后面再慢慢解释
1 | class Animal{ |
无论怎么样,关于协变的内容和之前是一样的,因为class类也是结构化类型,子类型的值是可以传递到需要父类型的地方。
12. 逆变
但是,如果是函数呢?
1 | function clone(f: (p: Pet) => Pet): void { |
现在有不同的函数
1 | function petToPet(p: Pet): Pet { |
将函数传递到clone
函数中
1 | clone(petToPet); |
petToDog
可以传递过去,但是petToAnimal却报错了。为什么呢?我们用伪代码模拟一下:
1 | function clone(f: (p: Pet) => Pet): void { |
如果传给clone
函数的f
返回的是Animal
,那就不能调用.run
方法。所以在编译时,Typescript
会确保传入的函数至少返回一个Pet
由此可以推断:函数和函数之间,在其他都一致的情况下,如果一个函数的返回类型是另一个函数返回类型的子类型,那么函数的返回类型是协变的
那么函数的参数类型呢?
1 | function petToPet(p: Pet): Pet { |
将函数传递到clone
中
1 | clone(petToPet); |
animalToPet
传递过去可以,但是dogToPet
却报错了,我们还是可以通过伪代码分析一下:
1 | function dogToPet(d: Dog): Pet { |
现在把dogToPet
传递给clone
,如果clone
函数中是Pet的实例,那么这就是不安全的。因为.bark()
只在Dog
中定义了,不是所有的Pet
都定义
也就是说:函数和函数之间,函数的参数个数一致的情况下,函数的参数是逆变的,也就是函数参数的父类型可以赋值给子类型
总结来说,在不考虑this
的情况下,满足以下条件,可以说函数A是函数B的子类型
1、函数A的参数数量小于或等于函数B的参数数量
2、函数A的返回类型是函数B返回类型的子类型,也就是协变的
3、函数A的各个参数的类型是函数B相应参数的父类型,参数是逆变的
考虑到历史遗留问题,Typescript中的函数其实默认会对参数和this类型做协变,这样并不安全,因此
strict
家族就有strictFunctionTypes
,默认打开strict:true
。当然也会打开strictFunctionTypes:true