Typescript 类型收窄

当 typescript 下的变量类型被定义为 any unknown Unions 甚至 Generics 时,我们对变量进行操作前需要知道其具体的类型,这种情况下,类型收窄 (Narrowing Types)可以提供帮助

举个例子 🌰 :

function isString(val: any) {
    return typeof val === 'string'
}

function test(val: string | number){
    if (isString(val)) {
        console.log(val.toUpperCase())   // 1
    } else {
        console.log(val.toFixed(2))  // 2
    }
}

上面的例子中,我们期望通过类型判断,让 typescript 在条件分支中能正确识别类型,这就是类型收窄的初衷。

然而,上面的代码事与愿违了,编辑器会在:

// 1 处报:
// Property 'toUpperCase' does not exist on type 'string | number'.
// 2 处报:
// Property 'toFixed' does not exist on type 'string | number'.

原因在于 typescript 是做静态分析检查的,而例子中val的值只有在运行时才能确定具体的类型,所以我们寄希望于动态运行的代码影响静态检查可以说是异想天开了。

那怎么办呢?typescript 提供了两种方案:type guards (类型守卫)assertions function (断言函数)

Type Guards

typeof

function test(val: string | number){
    if (typeof val ===  "string") {  // 1
        console.log(val.toUpperCase())
    } else { // 2
        console.log(val.toFixed(2))
    }
}

这样,在静态检查阶段就能知道进入 1 分支后类型一定是 string,由此,进入 2 分支则类型就可能是 number了。

typeof guard 中,类型可以是:string number boolean undefined symbol bigint object function

instanceof

function test(val: string[] | Promise<string>){
    if (val instanceof Array) {
        console.log(val.length)
    } else {
        console.log(val.then())
    }
}

这个很容易理解

equal

function test(a: string | number, b: string | boolean) {
    if (a === b) {
        // a 和 b 严格相等的话, 那 a 和 b 必然都为 string 类型
        a.toUpperCase()
    }
}

虽然 == 也可以做类型守卫,但 typescript 有个至今未修的问题,不清楚算不算 bug:

function test(a: string | number, b: string | boolean) {
    if (a == b) {
        // a 和 b 非严格相等,由于存在类型转换, a 与 b 并不一定都是 string 类型
        a.toUpperCase()
    }
}

test(1, true); // runtime error

最后执行test(1, true)静态检查通过,但运行时确报错了。

另外: !==!= 也可以做 type guard

strictNullChecks

启用 typescript 的 strictNullChecks 选项后,以下方式也能 type guard:

function test(val?: string | numm) {
    if (val == null) return;
    // 现在,val 只可能是 string 类型
    val.toUpperCase()
}

in

interface Bird {
    fly(): void
}

interface Fish {
    swim(): void
}

function test(animal: Bird | Fish) {
    if ('fly' in animal) {
        animal.fly()
    } else {
        animal.swim()
    }
}

Array.isArray

function test(arr?: string[]) {
    if (Array.isArray(arr)) {
        Array.push('Hello World')
    }
}

type predicates

当我们期望自定义类型守卫方法时便需要type predicates(类型谓词)了, 之前那段代码中的isString函数,稍加修改:

// 添加 val is string
function isString(val: any): val is string{
    return typeof val === 'string'
}

function test(val: string | number){
    if (isString(val)) {
        console.log(val.toUpperCase())   // 1
    } else {
        console.log(val.toFixed(2))  // 2
    }
}

通过在isString的返回类型声明处添加val is string,告知编辑器该函数返回true时,val一定是 string 类型,这样,编辑器在静态分析阶段就能在条件分支中推断变量类型了。

在 callback 中的问题

type People = {
  name: unknown
}

function test(people: People) {
    if (typeof people.name === 'string') {
        people.name.toUpperCase();
        [].forEach(() => {
          people.name.toUpperCase(); // Error: Object is possibly 'undefined'.
        });
        people.name.toUpperCase();
    }
}

明明已经收窄了类型,为什么在回调中依旧会出错呢?

因为编辑器也拿不准回调函数会被同步还是异步执行,如果被异步执行,people 就脱离了type guard所能管辖的区域了,在当前分支之外,可以给其赋值为其他类型。

那怎么解决?

type People = {
  name: unknown
}

function test(people: People) {
    if (typeof people.name === 'string') {
        people.name.toUpperCase();
        // peopleName 在分支所在作用域内,且类型确定为 string
        const peopleName = people.name;
        [].forEach(() => {
         // 分支之外不可能修改 peopleName
          peopleName.toUpperCase();
        });
        people.name.toUpperCase();
    }
}

Assertions Function

asserts «cond»

function assertTrue(condition: boolean, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

function test(val: unknown) {
    assertTrue(typeof val === 'string', `${val} is not string type`)
    // 现在 val 只会是 string 类型
    val.toUpperCase()
}

asserts «arg» is «type»

function assertIsString(val: unknown): asserts val is string {
    if (typeof val !== 'string') throw TypeError()
}

function test(val: number | string) {
    assertIsString(val)
    // 现在 val 只会是 string 类型
    val.toUpperCase()
}

使用场合

以上类型收窄的演示我们都在是 if 判断中,也可以在:

switch...case

function test(val: number | string) {
  switch(typeof val) {
    case 'string':
      val.toUpperCase();
      break;
    case 'number':
      val.toFixed();
      break;
  }
}

Array.prototype.filter

const arr: unknown[] = [1, 'hello', null];
const ret = arr.filter((item): item is string => typeof item === 'string');

上例中,ret会被推断为 string[]

最后

如何实现一个万能的 isTypeof 函数,一个参考:

function isTypeof<T>(val: unknown, typeVal: T): val is T {
    if (typeVal === null) return val === null;
    return val !== null && (typeof val === typeof typeVal)
}

function test(val: unknown) {
    if (isType(val, '....something')) {
        // now, val is string
        val.toUpperCase()
    }
} 

当然,上例中别扭的地方在于isTypeOf的第二个参数实际只是做类型标识,并无其他用处,改进版本:

function isTypeOf(val: unknown, type: 'string'): val is string
function isTypeOf(val: unknown, type: 'boolean'): val is boolean
function isTypeOf(val: unknown, type: 'number'): val is number
function isTypeOf(val: unknown, type: string): boolean {
    return typeof val === type
}

参考