Typescript 系列基础篇(二) TS 中的函数
Typescript 系列基础篇(二) TS 中的函数
函数在我们日常代码中占有绝对重要的地位,深入了解 TS 中函数的使用对我们的学习十分有利。如果你还不了解泛型函数、函数签名、函数重载,那么阅读本文将让你对 TS 中的函数有一个更加细致的理解,必能有所收获。
一、返回值
我们在声明一个函数 / 方法时,可以在括号后加上类型注释,以约束其返回值的类型,如果没有明确约束返回值的类型,则将其推论为 any 类型。除了void
和any
之外,其它所有的类型都应该有相应类型的返回值。
- 返回值如果不是约束的类型,或者约束了类型却没有
return
相应的类型,则会报错:
1 | // 声明变量时由初始值'cc'进行类型推论,得出_name为string类型 |
- 当我们实际的返回值有可能不是约束的类型时,也是不正确的:
1 | let _name3: string | number = "cc"; |
- 这种情况尤其容易发生在字面量类型上:
1 | // _name4经类型推论判定为string类型 |
- 函数的返回值为空时,使用
void
类型,此时可以return undefined
,return null
,也可以不写return
,会默认返回undefined
:
1 | let _name = "cc"; |
二、参数
在TS
中我们往往需要对函数的参数添加类型注释,如果不添加类型注释,则该参数将被类型推论为any
。TS
不仅约束了传参时实参的类型,也约束了在函数内部形参的类型。
1 | let _name = "cc"; |
有时候,我们的参数比较复杂,例如多种类型的组合:string | number
,这时候我们需要进行类型缩减,以防在return
或参数调用方法等情况下出现问题。
1 | let _name = "cc"; |
有时候,某个参数不是必须传的,就可以在形参后加上英文问号”?
“来表示可选参数,如果调用函数时不传该参数,则该参数为undefined
。因此,在函数体内部,该参数有可能是undefined
,也需要进行类型缩减。
1 | let userInfo: { name: string; age: number; gender: 1 | 2 | 3 }; |
三、函数类型表达式
TS 中可以使用箭头函数的形式来定义一个函数类型:(a: Type1, b: Type2, ...) => TypeN
表示接收的参数名称为a
, b
, …,类型分别为Type1
, Type2
,…,返回值类型为TypeN
的函数。
1 | // 定义了类型Fn1是一个函数,接收一个string类型的name和number类型的age为参数, |
在声明对象的方法时,可以很方便地使用函数类型表达式:
1 | // 定义一个User接口,其中包含interest方法,需要传入一个string类型的参数, |
四、类型缩减
在函数中,我们会经常遇到形参是组合类型或可选参数的情况,这时候我们就需要进行类型缩减,对该参数的类型抽丝剥茧,从而在每个具体的子类型时做相应的操作,防止类型出错。在该过程中,越往后该参数可能的类型范围就越小。
主要有 控制流分析:if-else
或 switch-case
。
(一) 控制流分析
通过 if,else 等控制流语句来逐步缩减参数的类型范围。
typeof
类型守卫
在下面的例子中,我们使用了typeof
这个 type gurads
类型守卫,typeof
会返回一些列固定的字符串,我们根据这些字符串来减少类型范围。
1 | type Fn = (name?: string | number) => string; |
typeof
的返回值:
“
string
““
numbrt
““
bigint
““
boolean
““
symbol
““
undefined
““
object
““
function
“
可以看到,typeof
无法检测出null
这个空值,typeof null
会返回”object
“,因此,我们可以辅以“truthiness
”检测进行真值校验。
Truthiness narrowing
真值校验利用
true
和false
来进行真值条件判断,从而达到类型缩减的目的。
1 | type Fn = (name?: string) => string; |
下面列举出使用 if
会得到 false
的值,根据官方文档的描述,除了以下列举的值之外,其它的值都会返回true
。
0
NaN
“” 空字符串
0n
数字0
+ 字母n
,是bigint
类型的0
null
undefined
如果我们想把任何值转化为相应的boolean
类型,可以利用布尔否定符”!
“,任何值经过双重否定之后都会转化为相应的布尔值。
1 | !!0; // false |
Equality narrowing
等值校验利用已知条件进行等值校验,从而 TS 可以推断出相应的参数类型,达到类型缩减的目的。
in
操作符使用表达式
"value" in x
,来判断对象里是否存在某个属性,来进行类型缩减。
1 | type Fish = { |
使用
instanceof
用于
Array
,Date
等引用类型。
(二) 类型预言
想要定义一个自定义的类型守卫,我们通常可以使用一个返回值是类型预言的函数。
类型预言格式:param is Type
,随后我们可以用该函数来进行类型缩减。
1 | type Fish = { |
注意如果animal
的的方法不是swim
而是bark
,则TS
将会进行类型推论,得到这个animal
是Dog
,便已经排除了Fish
类型。此时,在我们的 if 分支里包含了animal
是Dog
的情况,而在else
分支里 animal
就是never
类型了。
(三) 解析联合类型
在上面的例子中,我们分析了一些较为简单的类型。但是实际上,稍微复杂些的类型也是非常常见的。在官方文档中,给了一个例子:我们定义一个用于表示形状的接口Shape
,用 kind
属性来表示是圆形circle
还是正方形square
(字面量联合类型,防止单词拼写错误),圆形仅需要一个半径radius
属性,正方形仅需要边长属性 side_length
。因此我们使用可选属性,如果是circle
,则有radius
属性而没有side_length
属性,反之同理。
1 | interface Shape { |
接下来我们需要一个求面积的函数,参数为Shape
类型。由于参数radius
和side
都是可选的,因此都可能为空值。按照常理,我们会根据 kind
属性的值来判断是圆形还是方形,从而使用不同的面积公式:
1 | function getArea(obj: Shape) { |
但是此时你会发现,在严格空值检查下,这段代码会报错。因为radius
和side
都是可选属性,因此它们都可能为空值。当然,这里我们可以使用非空断言,但是也许我们可以用更合理的方式:给circle
和square
定义不同的接口,毕竟它们是两个完全不同的东西。此时我们的getArea
函数就不会再出现上述的问题。
1 | interface Circle { |
通过合理设计接口,能使问题得到更加优雅的解决方案。
(四) never
类型
当我们进行类型缩减时,一旦所有可能的类型都被缩减完了,如果继续缩减,例如再加一个else
分支,我们就会得到一个never
类型。TS
使用never
类型来告诉我们,当前的情况是tan ( Math.PI / 2 )
。never
类型可以被赋值给任意类型,但是任意其它类型都不能被赋值给never
类型(除了never
本身之外)。这个特性常用于穷举校验。
(五) 穷举校验
我们在进行类型缩减时,有时候无法考虑到所有的情况。因此,可以使用穷举校验,为了避免有类型被遗漏。穷举校验利用了上述never
类型的特性,在控制流的最后一个分支里,(如switch
语句的default
分支,if
语句末尾的else
分支),尝试把 进行类型缩减的参数 赋值给一个 never
类型的变量。由于只有never
类型可以被赋值给never
类型,一旦有我们考虑不周全,参数有类型遗漏了,那么在最后的分支里,该参数的类型就不会是never
,无法被赋值给never
类型的变量,TS
便会报错来提示我们。而如果我们考虑完了所有的类型情况,则该参数在最后一个分支里便是never
类型,可以被赋值给never
类型的变量,TS
就不会报错。因此,通过穷举检查的方式,我们只需要关注最后一个分支里是否有相应的报错,就能知晓我们是否考虑到了所有的类型情况。
1 | interface Circle { |
五、函数进阶
前面已经介绍了函数类型表达式,下面我们来了解下更多关于函数的知识。
(一) 函数签名
- 调用签名
函数也是一种对象,可以有自己的属性。但是使用函数类型表达式的时候无法同时声明函数的属性。调用签名描述了一种函数类型,包含了函数的属性、调用函数时应传递的参数以及返回值。使用调用签名可以很方便地解决函数类型表达式的不足。
1 | // 声明调用签名,调用签名是一种类型,其名字可以任意取 |
调用签名 vs 函数类型表达式:
- 函数类型表达式十分简洁
- 调用签名可以声明函数的属性
- 调用前面在 参数列表 和 返回值 之间使用冒号 “
:
“ ,而函数类型表达式使用箭头 “=>
“
- 构造签名
函数除了可以被直接调用之外,还可以使用 new
操作符来调用。构造签名描述了函数在使用 new
操作符调用时的传参和返回值。
1 | type ConstructSignatureFn = { |
- 混合签名
对于有些比较特殊的函数比如Date
,直接调用和使用new
操作符调用得到的结果是一样的,这种函数类型可以使用混合签名,将调用签名和构造签名写在一个类型对象里。
1 | interface CallOrConstruct { |
- 重载签名 和 实现签名
将在函数重载章节介绍。
(二) 泛型函数
- 基础
此前,我们在声明函数时,会直接给 形参 和 返回值 添加类型注释,在调用时传入相应类型的值。以这样的形式声明的函数,其传参和返回值的类型都是固定的。那有没有什么方式,能让我们调用函数时传参的类型能灵活多样呢?泛型函数正是我们想要的。
泛型函数:高度抽象化的类型。在声明函数时将类型抽象化( 可以是多个类型 ):在函数名后面加上尖括号,里面为抽象化的类型名 (例如:<T, K, U, ... >
,其中 T, K, U
是类型参数,各代表一种类型,至于具体是什么类型,在调用函数时由传入的类型决定。),在调用函数时再具体化,传入实际的类型,一旦传入类型,所有出现该泛型的地方,都会替换为这个传入的类型。如果没有传入明确的类型,则TS
会进行类型推论,自动判断Type
的类型。(T,K,U
等可以用任何你喜欢的词来替代,不过用这些字母会显得比较简洁。)
1 | // <Type>为泛型,Tpye任意代表一种类型, |
调用函数时可以传入任意实际类型:
1 | // 类型推论判断Type为string |
泛型的概念将类型进行了抽象化,使得函数可以在调用时传入需要的类型,从而增加了函数的通用性。泛型的名字 Type 可以随意取,注意相同的泛型代表着同一种类型。
- 泛型约束
我们知道,泛型可以定义多个,例如<Type1, Type2, ...>
,每个泛型都代表着一种类型,它们可以相同,也可以不同,具体分别是什么类型,都由该函数调用时传入的类型来决定。然而,到目前为止,我们定义的泛型都是和其它类型无关的。很多时候,我们会希望给泛型做一定的约束,让它只能是某些类型之中的一种。这时候,可以使用extends
关键字,来实现泛型约束。
1 | interface Person { |
- 指定类型参数
在前面的例子中,我们都没有手动传入类型,来指定泛型的实际类型,而是由TS
自动进行类型推论得出的。有一说一,TS
确实够机智。不过有些时候,由于泛型太抽象,仅仅靠TS
的类型推论,可能无法得出正确的结果。这时候,我们可以在调用函数时手动传入类型,来指定类型参数。毕竟我们永远比TS
知道的更多。下面来看一个官方的示例:
1 | function combine<Type>(arr1: Type[], arr2: Type[]): Type[] { |
这种情况下,便需要指定参数类型:
1 | const arr = combine<string | number>([1, 2, 3], ["hello"]); |
- 三个小细节写好泛型函数
- 尽可能少地使用泛型约束,让 TS 进行类型推论
- 尽可能少地使用类型参数
- 不要将没有重复使用的类型作为类型参数
(三) 函数重载
- 函数的可选参数
在前面的类型缩减章节中,我们知道,函数可以有可选参数,调用函数时,如果没有给可选参数传值,那么该参数的值便是undefined
, 这容易引发意想不到的错误。在函数中,我们可以通过真值校验来解决,也可以给参数一个默认值来解决 (同JS
)。但是,如果一个函数的参数中有回调函数,且该回调函数也有可选参数,则尤其容易引发错误。偷个懒,继续搬运官方的栗子:
1 | function myForEach(arr: any[], callback: (arg: any, index?: number) => void) { |
可见,使用可选参数不仅处理起来会有些麻烦,而且容易引发错误。因此,函数当有有限个不定数量或不同类型的参数时,更好的方案是函数重载。
- 函数重载
规定函数的形参与返回值的是重载签名,可以有多个重载签名;
兼容多个重载签名并进行逻辑处理的是实现签名,由于要兼容多套重载签名,因此会出现可选参数;
我们可以通过编写多套重载签名,来规定函数的不同调用方式 (传入不同数量或不同类型的参数以及不同类型的返回值)。然后通过实现签名来进行兼容的逻辑处理。
1 | // 定义两套重载签名 |
可以看到,实现签名 和 我们之前普通地使用可选参数的处理很相似,区别也很明显:尽管age
和gender
都是可选参数,但是通过重载签名,规定了age
和gender
必须同时传入或同时都不传,即规定了该函数的调用只能传入一个或三个参数。如果不进行函数重载,那么将多出一种只传入name
和age
这两个参数的情况要进行处理。可见,通过函数重载来规定函数不同的调用方式,可以使逻辑与结构更加清晰优雅。当我们进行函数重载时,一定要注意让实现签名兼容所有的重载签名(参数和返回值都要兼容处理)。
(四) 在函数中声明 this
一般而言,TS
会如同JS
一样,自动推断 this 的指向。JS
中不允许this
作为参数,不过TS
允许我们在函数中声明this
的类型,这种情况尤其在函数的回调参数callback
中较为常见。
1 | // filterUser个方法,其后是其调用签名 |
起初这个官方的示例我看了好几分钟没看懂,后来发现它的filterUsers
就是一个函数的调用签名,੯ੁૂ‧̀͡u\。这里声明了this
是User
类型,如果在该方法执行时,callback
中的this
不是User
类型,TS
就会提示我们代码写的有误。在函数中声明this
时,需要注意一点是,虽然在构造签名中,callback
使用箭头形式,但是在我们实际调用该方法时,callback
不能使用箭头函数,只能用function
关键字。毕竟众所周知,箭头函数没有自己作用域的this
,它使用的的this
同定义箭头函数时的上下文的 this。
(五) 其它的类型
void
函数的返回值设置为
void
,则返回空值。void
不等同于undefined
。返回值为
void
类型的函数,并不一定不能写return
语句。如果是通过函数表达式、函数签名等定义的函数类型,该类型的实例函数体中可以有return
语句,并且后面可以接任意类型的值,只不过它的返回值会被忽略。如果我们把这样的函数调用结果赋值给某个变量,则该变量的类型依然是void
。1
2
3
4
5
6
7
8
9type voidFunc = () => void;
const f1: voidFunc = () => {
// 可以return任意类型的值,但是会被忽略
return true;
};
// v1 的类型依然是void
const v1 = f1();但是,如果是通过字面量声明函数的返回值为
void
,则函数体内不能有return
语句。虽然官方文档里这么说,下面的栗子也摘自官方文档,但是我的vs code
编辑器里这样写并没有报错 ?。1
2
3
4
5
6
7
8
9function f2(): void {
// @ts-expect-error
return true;
}
const f3 = function (): void {
// @ts-expect-error
return true;
};object
是小写的
object
,而不是大写的Object
。这两者意义不同。unknown
never
有的函数永远没有返回值,例如在函数体内
return
之前抛出错误。never
类型也常用来做穷举校验。Funtion
这些类型基本都在[#
Typescript
系列:基础篇(一)]2022年了,了解一下 typescript系列:基础篇(一)? - 掘金介绍过了,此处不再赘述。
(六) 剩余参数
- 我才发现,原来
parameters
表示形参,arguments
表示实参。 - 剩余形参
剩余形参的使用基本同JS
一致,偷个懒直接拿官方栗子:
1 | // 倍乘函数,第一个参数为倍数,会返回后续所有参数各自乘以倍数而形成的数组 |
- 剩余实参
剩余实参常用于函数调用时对传递的参数 (数组、对象等) 进行展开,然而这里容易踩坑。以数组为例:
1 | const arr1 = [1, 2, 3]; |
数组的push
可以接收无限制个参数,因此可以直接展开参数arr2
。但是有的方法只能接收指定数量的参数,而在一般情况下,TS
认为数组的是可变的。如果直接对这类方法的进行数组参数的展开,会引起报错,因为TS
会认为数组里的成员数量可能是0
个或者多个,不符合该方法只接受指定数量的参数的要求。
1 | // 虽然数组现在只有两个成员,但是它的类型被推断为 number[], |
解决的办法也很简单,使用 as const
将数组的类型断言为不可变类型。此时的数组便被推论为元组类型。有关元组类型的内容,会在下一篇 对象类型篇中介绍。
1 | // 此时args长度不可变,被推论为元组类型 |
- 形参结构
没啥好说的,直接上官方示例。
1 | type NumberABC = { a: number; b: number; c: number }; |