Typescript之类型窄化(Narrowing)

322 阅读6分钟

当一个值的类型是联合类型时,对该值执行某个类型特有的方法时typescript会直接抛出警告,如下:

function toFixed(target: number | string, fractionDigits?: number) {
  target.toFixed(fractionDigits) // Property 'toFixed' does not exist on type 'string | number'.
}

由于toFixed方法只存在于number类型上,而此时typescript无法明确的知道target是number类型还是string类型。因此会抛出警告,而要解决这个问题,就必须要让typescript明确的知道target是number类型;这个时候就需要用到类型窄化,如下:

function toFixed(target: number | string, fractionDigits?: number) {
  if (typeof target === 'number') {
    target.toFixed(fractionDigits)
  }
  // do nothing
}

抛开类型注释不看,这段代码也经常会出现javascript代码中,在javascript代码中这么写是为了避免运行时代码出错。同样的,typescript也能理解typeof操作符的含义,通过typeof target === 'number'typescript能够明确的知道在这个if分支下,target的类型被锁定为number类型,从而避免了静态类型检查抛出警告,这就是类型窄化其中的一种。

类型窄化分为好几种,下面我们将一种一种的详细探究。

typeof 类型守卫(type guards)

typeof操作符在typescript的行为中与在javascript中的行为并无二致。它会返回一组特定的字符串

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

真值窄化(Truthiness narrowing)

当我们对一个值为null或者undefined变量进行读取属性时,javascript运行时会抛出异常,而typescript也会抛出警告,如下:

function getName(target: { name: string, } | null | undefined) {
  return target.name // Object is possibly 'null' or 'undefined'.
}

要移除这个警告就要用到真值窄化,如下:

function getName(target: { name: string, } | null | undefined) {
  if (target) {
    return target.name
  }
  return undefined
}

这样的代码在javascript中我们也经常写,typescript能够像明白typeof操作符一样,同样也能明白if条件操作符的含义。

等性窄化(Equality narrowing)

function example(x: number | string, y: string | boolean) {
  if (x === y) {
    x.toUpperCase()
    y.toUpperCase()
  } else {
    // do nothing
  }
}

如上代码所示,x为number或者string类型,y为string或者boolean类型。当通过全等号判断他们相等时,typescript就能明确的知道x和y都是string类型。因为在javascript中全等号两个变量是否相等,首先他们的类型得一致,否则将直接判断为不相等。 上面讲到的真值窄化案例也可以通过等性窄化来解决,如下

function getName(target: { name: string, } | null | undefined) {
  if (target != null) {
    return target.name
  }
  return undefined
}

in 操作符窄化

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
  return animal.fly();
}

javascript中有一种用来判断某个对象是否含有某个属性的操作符:in操作符,typescript也将其纳入类型窄化方法中的一种。如上代码,通过"swim" in animal判断typescript就能够明确的知道,animal是Fish类型还是Bird类型。

instanceof操作符窄化

在javascript中,有一种判断某个值是否为另一个值的实例的操作符:instanceof操作符;同样的,typescript也能明白这个操作符的含义,该操作符在面向对象编程中是十分有用的。示例如下:

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());
  } else {
    console.log(x.toUpperCase());
  }
}

赋值窄化

当你没有为某个变量提供明确的类型注释时,typescript可以通过对该变量的赋值自动推算出变量的类型,如下:

let bool = true
bool = 1 // Type 'number' is not assignable to type 'boolean'.

let x = Math.random() < 0.5 ? 10 : "hello world!"; // number | string
x = 1
x = '1'
x = true // Type 'boolean' is not assignable to type 'string | number'.

类型断言

除了上述提到的方法以外,还可以通过类型断言来进行类型窄化。 在typescript中断言分为两种:

  1. predicate
  2. assert

predicate

之前我们提到可以通过typeof来进行类型窄化,但是假如我们要将typeof value === 'number'封装成函数,例如打造一个类似lodash的库,那么仅仅依靠typeof就不管用了,看下面的代码:

function isNumber(value: any) {
  return typeof value === 'number'
}

function toFixed(target: number | string, fractionDigits?: number) {
  if (isNumber(target)) {
    target.toFixed(fractionDigits) // Property 'toFixed' does not exist on type 'string | number'. Property 'toFixed' does not exist on type 'string'.
  }
  // do nothing
}

typescript只能推断出isNumber返回的是布尔值,但却无法明确的知道返回布尔值是否代表value是不是number类型。因此typescript依旧会抛出警告,要解决这个问题就需要用到type predicate,如下:

function isNumber(value: any): value is number {
  return typeof value === 'number'
}

function toFixed(target: number | string, fractionDigits?: number) {
  if (isNumber(target)) {
    target.toFixed(fractionDigits)
  }
  // do nothing
}

assert

type assert的代码示例如下:

function toFixed(target: number | string, fractionDigits?: number) {
  (target as number).toFixed(fractionDigits)
}

通过as操作解决这个问题似乎省事很多,但其实这是一个危险的方案,尽量要避免使用。

never类型

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
 
type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      return shape
  }
}

如上代码所示,当我们case少一个分支时,typescript是无法帮助我们预知道这个bug的存在。要解决这个问题就需要用到一个特殊的类型: nevernever的特点如下:

  • never类型可以被赋值给任何类型,但是没有类型可以被赋值给never类型 利用这个特点,我们可以对以上代码进行一些改动,来解决当Shape的联合类型变多而我们又忘了在switch中添加一个case分支时typescript不会抛出警告的问题,代码如下:
interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
 
type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const res: never = shape // Type 'Triangle' is not assignable to type 'never'.
      return res
  }
}

由于漏了一个case分支,所以走到default分支时,shape的类型应该是Triangle。别忘了我们刚刚提到的never类型的特点:没有类型可以被赋值给never类型。所以此时typescript就会抛出警告,因此我们就知道修改代码时漏了加一个case分支,而不用等到运行程序进行测试的时候才发现问题。

总结

以上就是关于类型窄化的全部内容了,总的来说typescript保持了所有javascript既有的特性,即使这个特性在很多别的语言看来是比较怪异的。也正因如此,即使很多概念我们不了解,我们也会下意识的这么写,如:typeof类型守卫、真值窄化、等性窄化、in操作符窄化、instanceof操作符窄化、赋值窄化;也加入了一些新的概念,使得我们更容易的处理复杂的情况,如:type predicate/type assert/never类型。