类型守卫
JavaScript 作为一种动态语言,意味着其中的参数、值可以是多态(多种类型)。因此,我们需要区别对待每一种状态,以此确保对参数、值的操作合法。
举一个常见的场景为例,如下我们定义了一个可以接收字符串或者字符串数组的参数 toUpperCase,并将参数转成大写格式输出的函数 convertToUpperCase。
{
const convertToUpperCase = (strOrArray) => {
if (typeof strOrArray === 'string') {
return strOrArray.toUpperCase();
} else if (Array.isArray(strOrArray)) {
return strOrArray.map(item => item.toUpperCase());
}
}
}
在示例中的第 3 行、第 5 行,我们分别使用了 typeof、Array.isArray 确保字符串和字符串数组类型的入参在运行时分别进入正确的分支,而不至于入参是数组类型时,调用数组类型并不存在的 toUpperCase 方法,从而抛出一个“strOrArray.toUpperCase is not a function”的错误。
在 TypeScript 中,因为受静态类型检测约束,所以在编码阶段我们必须使用类似的手段确保当前的数据类型支持相应的操作。当然,前提条件是已经显式地注解了类型的多态。
比如如果我们将上边示例中的 convertToUpperCase 函数使用 TypeScript 实现,那么就需要显示地标明 strOrArray 的类型就是 string 和 string[] 类型组成的联合类型,如下代码所示:
{
const convertToUpperCase = (strOrArray: string | string[]) => {
if (typeof strOrArray === 'string') {
return strOrArray.toUpperCase();
} else if (Array.isArray(strOrArray)) {
return strOrArray.map(item => item.toUpperCase());
}
}
}
在示例中,convertToUpperCase 函数的主体逻辑与 JavaScript 中的逻辑完全一致(除了添加的参数类型注解)。
在 TypeScript 中,第 3 行和第 5 行的 typeof、Array.isArray 条件判断,除了可以保证转译为 JavaScript 运行后类型是正确的,还可以保证第 4 行和第 6 行在静态类型检测层面是正确的。
很明显,第 4 行中入参 strOrArray 的类型因为 typeof 条件判断变成了 string,第 6 行入参 strOrArray 的类型因为 Array.isArray 变成了 string[],所以没有提示类型错误。而这个类型变化就是类型缩小,这里的 typeof、Array.isArray 条件判断就是类型守卫。
从示例中,我们可以看到类型守卫的作用在于触发类型缩小。实际上,它还可以用来区分类型集合中的不同成员。
类型集合一般包括联合类型和枚举类型,下面我们看看如何区分联合类型。
如何区分联合类型?
首先,我们看一下如何使用类型守卫来区分联合类型的不同成员,常用的类型守卫包括switch、字面量恒等、typeof、instanceof、in 和自定义类型守卫这几种。
1. switch
我们往往会使用 switch 类型守卫来处理联合类型中成员或者成员属性可枚举的场景,即字面量值的集合,如以下示例:
{
const convert = (c: 'a' | 1) => {
switch (c) {
case 1:
return c.toFixed(); // c is 1
case 'a':
return c.toLowerCase(); // c is 'a'
}
}
const feat = (c: { animal: 'panda'; name: 'China' } | { feat: 'video'; name: 'Japan' }) => {
switch (c.name) {
case 'China':
return c.animal; // c is "{ animal: 'panda'; name: 'China' }"
case 'Japan':
return c.feat; // c is "{ feat: 'video'; name: 'Japan' }"
}
};
}
在上述示例中,因为 convert 函数的参数及 feat 函数参数的 name 属性都是一个可被枚举的集合,所以我们可以使用 switch 来缩小类型。
比如第 5 行中 c 的类型被缩小为数字 1,第 7 行的 c 被缩小为字符串 'Japan',第 13 和 15 行的 c 也被缩小为相应的接口类型。因此,我们对参数 c 进行相关操作时,也就不会提示类型错误了。
2. 字面量恒等
switch 适用的场景往往也可以直接使用字面量恒等比较进行替换,比如前边的 convert 函数可以改造成以下示例:
const convert = (c: 'a' | 1) => {
if (c === 1) {
return c.toFixed(); // c is 1
} else if (c === 'a') {
return c.toLowerCase(); // c is 'a'
}
}
在以上示例中,第 3 行、第 5 行的类型相应都缩小为了字面量 1 和 'a'。
**建议:**一般来说,如果可枚举的值和条件分支越多,那么使用 switch 就会让代码逻辑更简洁、更清晰;反之,则推荐使用字面量恒等进行判断。
3. typeof
当联合类型的成员不可枚举,比如说是字符串、数字等原子类型组成的集合,这个时候就需要使用 typeof。
typeof 是一个比较特殊的操作符,我们可以使用它对 convert 函数进行改造,如下代码所示:
const convert = (c: 'a' | 1) => {
if (typeof c === 'number') {
return c.toFixed(); // c is 1
} else if (typeof c === 'string') {
return c.toLowerCase(); // c is 'a'
}
}
在上述示例中,因为 typeof c 表达式的返回值类型是字面量联合类型 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function',所以通过字面量恒等判断我们把在第 2 行和第 4 行的 typeof c 表达式值类型进行了缩小,进而将 c 的类型缩小为明确的 string、number 等原子类型。
4. instanceof
此外,联合类型的成员还可以是类。比如以下示例中的第 9 行和第 11 行,我们使用了 instanceof 来判断 param 是 Dog 还是 Cat 类。
{
class Dog {
wang = 'wangwang';
}
class Cat {
miao = 'miaomiao';
}
const getName = (animal: Dog | Cat) => {
if (animal instanceof Dog) {
return animal.wang;
} else if (animal instanceof Cat) {
return animal.miao;
}
}
}
这里我们可以看到,第 10 行、第 12 行的 animal 的类型也缩小为 Dog、Cat 了。
5. in
当联合类型的成员包含接口类型(对象),并且接口之间的属性不同,如下示例中的接口类型 Dog、Cat,我们不能直接通过“ . ”操作符获取 param 的 wang、miao 属性,从而区分它是 Dog 还 是 Cat。
{
interface Dog {
wang: string;
}
interface Cat {
miao: string;
}
const getName = (animal: Dog | Cat) => {
if (typeof animal.wang == 'string') { // ts(2339)
return animal.wang; // ts(2339)
} else if (animal.miao) { // ts(2339)
return animal.miao; // ts(2339)
}
}
}
这里我们看到,在第 9~12 行都提示了一个 ts(2339) Dog | Cat 联合类型没有 wang、miao 属性的错误。
这个时候我们就需要使用 in 操作符来改造一下 getName 函数, 这样就不会提示类型错误了,如下代码所示:
const getName = (animal: Dog | Cat) => {
if ('wang' in animal) { // ok
return animal.wang; // ok
} else if ('miao' in animal) { // ok
return animal.miao; // ok
}
}
这里我们可以看到,第 3 行、第 4 行中的 animal 的类型也缩小成 Dog 和 Cat 了。
6. 自定义类型守卫
我们将使用类型谓词 is,比如封装一个 isDog 函数来区分 Dog 和 Cat,如下代码所示:
const isDog = function (animal: Dog | Cat): animal is Dog {
return 'wang' in animal;
}
const getName = (animal: Dog | Cat) => {
if (isDog(animal)) {
return animal.wang;
}
}
这里我们在 getName 函数第 5 行的条件判断中使用了 isDog 将 animal 的类型缩小为 Dog,这样第 6 行就可以直接获取 wang 属性了,而不会提示一个 ts(2339) 的错误。