类型窄化

TS中的类型是可以组合使用的。

联合和窄化

type Padding = number | string

function padLeft(padding: Padding, input: string): string {
  // ...
}
typescript

当我们编写上述代码会遇到一个问题,我们需要用 typeof 判断 padding 的类型。

当然作为一个 number | string 的联合类型可以赋值成 number 或者 string。

let x: number | string = 1
x = 'Hello'
typescript

如果不判断:

function padLeft(padding: number | string, input: string) {
  return new Array(padding + 1).join(' ') + input
  // 运算符“+”不能应用于类型“string | number”和“number”。
}
typescript

于是我们可以增加 typeof 的判断:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return new Array(padding + 1).join(' ') + input
  }
  return padding + input
}
typescript

当进行 if + typeof 操作后,ts 可以识别变窄后的类型,称为窄化(Narrowing)。上面 Narrowing 的能力,可以让 TS 清楚的知道 padding 是数字还是字符串。

在实现层面,TS 会认为 typeof padding = "number" 。这样的表达式是一种类型守卫(type guard)表达式。 当然这是纯粹实现层面的概念,准确来说是 if + type guard 实现了 Narrowing。

类型窄化(Type Narrowing)根据类型守卫(Type Guard)在子语句块重新定义更具体的类型。

typeof 守卫

// 返回值
"string"
"number"
"bigint"
"boolean"
"symbol"
"undefined"
"object"
"function"
typescript

注意,因为 typeof null === 'object'。因此:

function printAll(strs: string | string[] | null) {
  if (typeof strs === 'object') {
    for (const s of strs) {
      // Object is possibly 'null'
      console.log(s)
    }
  } else if (typeof strs === 'string') {
    console.log(strs)
  } else {
    // do nothing
  }
}
typescript

真值窄化

真值窄化(Truthiness narrowing)。

Javascript 有一张复杂的真值表,总结下来这些值都会拥有 false 的行为:

0
NaN
"" (the empty string0n  (the bigint version of zero)
null
undefined
typescript

我们也可以通过真值实现窄化。例如我们可以避免:TypeError:null is not iterable 错误。

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === 'object') {
    for (const s of strs) {
      // Object is possibly 'null'
      console.log(s)
    }
  } else if (typeof strs === 'string') {
    console.log(strs)
  } else {
    // do nothing
  }
}
typescript

再举个例子:

function multiplyAll(values: number[] | undefined, factor: number) {
  if (!values) {
    return values
  }
  return values.map(x => x * factor)
}
typescript

真值(Truthiness narrowing)窄化可以帮助我们更好的应对 null/undefined/0 等值

相等性窄化

在窄化中有一类隐式的窄化方法,就是想等性窄化。===!====!= 都可以用来窄化类型。例如:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // s is string
  } else {
    // x is string | number
    // y is string | boolean
  }
}
typescript

再看一个例子:

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === 'object') {
      for (const s of strs) {
      }
    } else if (typeof strs === 'string') {
    }
  }
}
typescript
interface Container {
  value: number | null | undefined
}

function multiplyValue(container: Container, factor: number) {
  if (container.value != null) {
    container.value *= factor
  }
}
typescript

in 操作符窄化

in 操作符的作用是检验对象中是否有属性。

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

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

不过我们为什么不用 instaceof Fish ?因为 type 没有运行时,代码转化后就不存在了。

instanceof 窄化

instanceof 也可以做窄化,不过类型不能是 type,而是真实存在的 Function 类型。

function logValue(x: Date | string) {
  if (x instanceof Date) {
    // s is Date
  } else {
    // x is string
  }
}
typescript

组合类型推导

有时候 TypeScript 会推导出组合类型。

let x = Math.random() < 0.5 ? 10 : 'hello world!'
typescript

这个时候 x 是 number | string。当然,这里有个问题是 number | string 的类型可以赋值成 number 或者 string

控制流分析

TypeScript 是如何做到类型窄化的?

首先在语法分析阶段,Typescrpt 的编译器会识别出类型卫兵表达式。包括一些隐性的类型卫兵,比如真值表达式、instaceof 等等。然后在语法分析的时候,Typescript 遇到控制流关键字 if/while 等,就会看看这里有没有需要分析的窄化操作。例如:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return new Array(padding + 1).join(' ') + input
  }
  return padding + input
}
typescript
  • 首先 ts 会看到一个卫兵表达式:typeof padding === 'number'
  • 然后 ts 会对返回值 return padding + input 以及 return new 分别做窄化;
  • 窄化的本质是重新定义类型。

很多语句都会触发窄化:

function example() {
  let x: string | number | boolean

  x = Math.random() < 0.5
  // x = boolean

  if (Math.random() < 0.5) {
    x = 'Hello'
    // x:string
  } else {
    x = 100
    // x: number
  }

  return x
  // x: string | number
}
typescript

类型断言

类型断言(Type Assertions/Predicate)。

Assertion 和 predicate 翻译过来都是断言。在计算机中,Assertion 通常是断言某个表达式的值是不是 true/false。Assertion 在很多的测试库中被使用,比如 asset.equals(a, 1) 。从语义上,这里在断言 a 的值是 1 (a === 1 是 true)。

Assertion 在说某个东西是什么。

Predicate 通常是一个函数,返回值是 true/false,比如说 list.filter(x => x.score > 500),x => s.score > 500 这个函数是一个 predicate 函数。

Prediate 是一个返回 true/false 的函数。

TS 中有两个断言操作符,Assertion 操作符 aspredicate 操作符 is

as 操作符提示 TypeScript 某种类型是什么(当用户比 TypeScript 更了解类型的时候使用)。is 操作符是用户自定义的类型守卫,用于帮助 TypeScript Narrowing。

案例:

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined
}

let pet = {
  fly: () => {}
}

if (isFish(pet)) {
  // isFish 成为了 Type Guard
  pet.swim()
} else {
  pet.fly()
}
typescript

pet is FishisFish 具有的窄化功能,如果不定义,会直接报错。

那么 as/is 符不符合计算机标准语言中 Assertion/Predicate 的含义?

  • as 是比较符合 assertion 的含义的
  • is 如果是一个 predicate,是应该返回一个值的。不过 pet is Fish 在这只是起到类型卫兵的作用,含义略微有些差别。

判别的联合

判别的联合(Discriminated unions)。

interface Shape {
  kind: 'circle' | 'square'
  radius?: number
  sideLength: number
}

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2
}
typescript

这样判断有什么问题吗?如果是下面这样呢?

function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius ** 2
  }
}
typescript

上面这两种写法都存在问题,因为 radius 可能不存在。我们可以使用非 Null 断言操作符强制判断 radius 不能为空。

function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius! ** 2
  }
}
typescript

针对上面的代码其实还有优化空间。Circle 应该是一种单独的类型,Shape 可能还有 rect 等。

interface Circle {
  kind: 'circle'
  radius: number
}

interface Square {
  kind: 'square'
  sideLength: number
}

type Shape = Circle | Square

function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    // Narrowing
    return Math.PI * shape.radius ** 2
  }
}
typescript

再整理下:

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

never 类型

Never,即不应该出现的意思。Never 类型代表一个不应该出现的类型。因此对 Never 的赋值,都会报错。

比如下面处理 default 的逻辑:

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

总结

类型窄化解决了联合类型校验的问题。TS 是 JS 的超集,TS 会尽量避免新增特性。