跳到主要内容

类型守卫

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) 的错误。

如何区别枚举类型?

枚举类型是命名常量的集合,所以我们也需要使用类型守卫区分枚举类型的成员。

回想一下枚举类型的若干特性,因为这将决定使用哪几种类型守卫来区分枚举既是可行的,又是安全的。

**特性 1:**枚举和其他任何枚举、类型都不可比较,除了数字枚举可以与数字类型比较之外;

**特性 2:**数字枚举极其不稳定。

熟悉了这些特性后,得出一个结论:最佳实践时,我们永远不要拿枚举和除了自身之外的任何枚举、类型进行比较。

🌰 看一个具体的示例:

{
enum A {
one,
two
}
enum B {
one,
two
}
const cpWithNumber = (param: A) => {
if (param === 1) { // bad
return param;
}
}
const cpWithOtherEnum = (param: A) => {
if (param === B.two as unknown as A) { // ALERT bad
return param;
}
}
const cpWithSelf = (param: A) => {
if (param === A.two) { // good
return param;
}
}
}

在第 10~14 行的函数 cpWithNumber 中,第 11 行我们将类型是枚举 A 的入参 param 和数字字面量 1 进行比较,因为 A 是数字枚举,所以 param 可以和 1 进行比较,而不会提示一个 ts(2367) 条件判断恒为 false 的错误。

因为数字枚举不稳定,所以默认情况下 A.two 的值会是 1,因此第 11 行的条件判断在入参为 A.two 的时候为真。但是,如果我们给枚举 A 的成员 one 指定初始值 1,第 11 行的条件判断在入参为 A.two 的时候就为否了,因为 A.two 值变成了 2,所以这不是一个安全的实践。

在调用函数 cpWithNumber 的时候,我们使用数字类型做入参也是一种不安全的实践,原因同上。

示例中第 15 ~ 19 行的函数 cpWithOtherEnum,我们使用了双重类型断言将枚举类型 B 转换为 A,主要是为了避免第 16 行提示一个 ts(2367) 错误,所以这同样也是一种不安全的实践。因为一旦 A 和 B 的结构出现了任何差异(比如给成员指定了不同的初始值、改变了成员的顺序或者个数),都会导致第 16 行的条件判断逻辑时真时否。

注意:有时候我们确实避免不了像示例中第 16 行这样使用双重类型断言来绕过 TypeScript 静态类型检测,比如使用基于同一个 Swagger 定义自动生成的两个枚举类型。此时,我们就需要极其谨慎,而且还需要添加警示信息进行说明,比如第 16 行添加的 "ALERT" 注释。

最安全的实践是使用第 21 行区分枚举成员的判断方式。

以上结论,同样适用于使用其他类型守卫(例如 switch)来区分枚举成员的场景。

⚠️ 注意:字面量成员枚举可等价为字面量成员类型组成的联合类型,所以类型守卫可以让字面量成员枚举发生类型缩小。比如第 22 行中 param 的类型是 A.two,此时如果我们在 VS Code 中 hover 到 param 变量上,则会看到一个信息验证提示。

失效的类型守卫

失效的类型守卫指的是某些类型守卫应用在泛型函数中时不能缩小类型,即失效了。

比如我们改造了一个可以接受泛型入参的 getName 函数

const getName = <T extends Dog | Cat>(animal: T) => {
if ('wang' in animal) {
return animal.wang; // ts(2339)
}
return animal.miao; // ts(2339)
};

在上述示例中,虽然我们在第 2 行使用了 in 类型守卫,但是它并没有让 animal 的类型如预期那样缩小为 Dog 的子类型,所以第 3 行的 T 类型上没有 wang 属性,从而提示一个 ts(2339) 的错误。所以第 5 行的 animal 也不会缩小为 Cat 的子类型,从而也会提示一个 ts(2339) 的错误。

可一旦我们把 in 操作换成自定义类型守卫 isDog 或者使用 instanceOf,animal 的类型就会缩小成了 Dog 的子类型(T & Dog),所以第 3 行不会提示 ts(2339) 的错误。由此可见,in 和 instanceOf、类型谓词在泛型类型缩小上是有区别的。

const getName = <T extends Dog | Cat>(animal: T) => {
if (isDog(animal)) { // instanceOf 亦可
return animal.wang; // ok
}
return animal.miao; // ts(2339)
};

但是,在缺省的 else 条件分支里,animal 的类型并没有缩小成 Cat 的子类型,所以第 5 行依旧会提示一个 ts(2339) 的错误(这是一个不太科学的设计,所幸在 TypeScript 4.3.2 里已经修改了)。

这个时候,就需要使用类型断言,如下代码所示:

const getName = <T extends Dog | Cat>(animal: T) => {
if (isDog(animal)) { // instanceOf 亦可
return animal.wang; // ok
}
return (animal as Cat).miao; // ts(2339)
};

在第 5 行,我们把 animal 的类型断言为 Cat,并获取了它的 miao 属性。