• 【TypeScript】类型守卫:如何有效地保障类型的安全性?


    自我介绍:大家好,我是吉帅振的网络日志(其他平台账号名字相同),互联网前端开发工程师,工作5年,去过上海和北京,经历创业公司,加入过阿里本地生活团队,现在郑州北游教育从事编程培训。

    一、前言

    JavaScript 作为一种动态语言,意味着其中的参数、值可以是多态(多种类型)。因此,我们需要区别对待每一种状态,以此确保对参数、值的操作合法。举一个常见的场景为例,如下我们定义了一个可以接收字符串或者字符串数组的参数 toUpperCase,并将参数转成大写格式输出的函数 convertToUpperCase。

    {
      const convertToUpperCase = (strOrArray) => {
        if (typeof strOrArray === 'string') {
          return strOrArray.toUpperCase();
        } else if (Array.isArray(strOrArray)) {
          return strOrArray.map(item => item.toUpperCase());
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在示例中的第 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());
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在示例中,convertToUpperCase 函数的主体逻辑与 JavaScript 中的逻辑完全一致(除了添加的参数类型注解)。在 TypeScript 中,第 3 行和第 5 行的 typeof、Array.isArray 条件判断,除了可以保证转译为 JavaScript 运行后类型是正确的,还可以保证第 4 行和第 6 行在静态类型检测层面是正确的。

    很明显,第 4 行中入参 strOrArray 的类型因为 typeof 条件判断变成了 string,第 6 行入参 strOrArray 的类型因为 Array.isArray 变成了 string[],所以没有提示类型错误。而这个类型变化就是 04 讲中学习的类型缩小,这里的 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' }"
        }
      };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在上述示例中,因为 convert 函数的参数及 feat 函数参数的 name 属性都是一个可被枚举的集合,所以我们可以使用 switch 来缩小类型。比如第 5 行中 c 的类型被缩小为数字 1,第 7 行的 c 被缩小为字符串 ‘Japan’,第 13 和 15 行的 c 也被缩小为相应的接口类型。因此,我们对参数 c 进行相关操作时,也就不会提示类型错误了。

    1. 字面量恒等

    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'
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在以上示例中,第 3 行、第 5 行的类型相应都缩小为了字面量 1 和 ‘a’。建议:一般来说,如果可枚举的值和条件分支越多,那么使用 switch 就会让代码逻辑更简洁、更清晰;反之,则推荐使用字面量恒等进行判断。

    1. typeof

    反过来,当联合类型的成员不可枚举,比如说是字符串、数字等原子类型组成的集合,这个时候就需要使用 typeof。typeof 是一个比较特殊的操作符(15 讲中会再详细地介绍它),我们可以使用它对 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'
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在上述示例中,因为 typeof c 表达式的返回值类型是字面量联合类型 ‘string’ | ‘number’ | ‘bigint’ | ‘boolean’ | ‘symbol’ | ‘undefined’ | ‘object’ | ‘function’,所以通过字面量恒等判断我们把在第 2 行和第 4 行的 typeof c 表达式值类型进行了缩小,进而将 c 的类型缩小为明确的 string、number 等原子类型。

    1. 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;
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这里我们可以看到,第 10 行、第 12 行的 animal 的类型也缩小为 Dog、Cat 了。接下来我们看看更复杂的情况——in。

    1. 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)
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这里我们看到,在第 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
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这里我们可以看到,第 3 行、第 4 行中的 animal 的类型也缩小成 Dog 和 Cat 了。最后我们要介绍的是自定义类型守卫,确切地讲是自定义函数,

    1. 自定义类型守卫

    这时我们将使用 05 讲中学习过的类型谓词 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;
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这里我们在 getName 函数第 5 行的条件判断中使用了 isDog 将 animal 的类型缩小为 Dog,这样第 6 行就可以直接获取 wang 属性了,而不会提示一个 ts(2339) 的错误。除了联合类型之外,另外一个类型集合是枚举类型,下面我们聊聊如何区别枚举类型。

    三、如何区别枚举类型?

    如 09 讲中介绍,枚举类型是命名常量的集合,所以我们也需要使用类型守卫区分枚举类型的成员。先回想一下枚举类型的若干特性,因为这将决定使用哪几种类型守卫来区分枚举既是可行的,又是安全的。特性 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;
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    在第 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,所以这不是一个安全的实践。

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

    不过,类型守卫实际上也会有力不足心的时候,下面我们一起看看失效的类型守卫。

    四、失效的类型守卫

    失效的类型守卫指的是某些类型守卫应用在泛型函数中时不能缩小类型,即失效了。比如我们改造了一个可以接受泛型入参的 getName 函数,如下代码所示:

    const getName = (animal: T) => {
      if ('wang' in animal) {
        return animal.wang; // ts(2339)
      }
      return animal.miao; // ts(2339)
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在上述示例中,虽然我们在第 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 = (animal: T) => {
      if (isDog(animal)) { // instanceOf 亦可
        return animal.wang; // ok
      }
      return animal.miao; // ts(2339)
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    const getName = (animal: T) => {
      if (isDog(animal)) { // instanceOf 亦可
        return animal.wang; // ok
      }
      return (animal as Cat).miao; // ts(2339)
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    五、总结

    了解类型守卫的作用,区分类型集合中的不同成员,知晓如何区分联合类型和枚举类型。

  • 相关阅读:
    C++-继承-单继承-多继承-虚函数-内存结构分析-类分析-无源码-逆向分析(三)
    请求分页中的内存分配
    JSR303数据校验
    Yolov5的类激活图
    跨越千年医学对话:用AI技术解锁中医古籍知识,构建能够精准问答的智能语言模型,成就专业级古籍解读助手(LLAMA)
    skywalking
    1767. 寻找没有被执行的任务对(当时对递归不熟)(NO)
    KUKA机器人与Insight 配合
    springboot小商户茶叶信息管理毕业设计-附源码
    如何快速绘制任意角度的扇形?
  • 原文地址:https://blog.csdn.net/qq_42451979/article/details/126388108