TS 的必会技能,进阶类型的窄化

87 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 2 天,点击查看活动详情

本文是上一篇之后的进阶篇,在能干活的基础上,再进一步,干好活。类型的窄化其实用处还是挺广的,接下来,我们就一起了进入类型的窄化...

上一篇# TS 的必会技能,学完这些就可以干活了

类型的窄化

联合和窄化

type Padding = number | string

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

但是这样会遇到一个问题,接下来需要用 typeof 判断 padding 的类型。

当然一个是 number|string 的类型可以赋值成 number 或者 string

let x: number | string = 1
x = "Hello"

如果不判断:

function padLeft(padding: number | string, input: string) {
  return new Array(padding + 1).join(" ") + input;
  // Operator '+' cannot be applied to types 'string | number' and 'number'.
}

于是增加 typeof 的判断:

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

当进行了 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"

注意: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
  }
}

真值窄化(Truthiness narrowing)

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

0
NaN
"" (the empty string)
0n (the bigint version of zero)
null
undefined

我们也可以通过真值实现窄化:

比如避免:TypeError: null is not iterable 错误。

if (strs && typeof strs === "object") {
    for (const s of strs) {
        console.log(s);
    }
} 

再举个例子:

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

划重点:真值(Truthiness narrowing)窄化帮助我们更好的应对 null/undefined/0 等值。

相等性窄化

在窄化当中有一类隐式的窄化方法,就是相等性窄化。===, !==, ==, and != 都可以用来窄化类型。

举例:

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

再看一个例子:

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === "object") {
      for (const s of strs) {        
          // (parameter) strs: string[]
      }
    } else if (typeof strs === "string") {
          // (parameter) strs: string
    }
  }
}

再看一个例子:

interface Container {
  value: number | null | undefined;
}

function multiplyValue(container: Container, factor: number) {
  if (container.value !== null) {
    // container.value 是什么类型?
    container.value *= factor;
  }
}

in 操作符窄化

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

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


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

  return animal.fly();
}

特别提一下,为什么不用instanceof Fish ? 因为 type 没有运行时。

instanceof 窄化

instanceof 可以窄化,注意 Date 不能是 type 而是真实存在的 Function 类型。

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

组合类型推导

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

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

这个时候 x 是 number | string

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

控制流分析

你可能会问:Typescript 怎么做到窄化的?

首先在语法分析阶段,Typescript 的编译器会识别出类型卫兵表达式。包括一些隐性的类型卫兵,比如真值表达式、instanceof 等等。

那么在语义分析的时候,Typescript 遇到控制流关键字 if/while 等,就会看看这里有没有需要分析的窄化操作。例如:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}
  • 首先 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
}

类型断言(Type Assertions/Predicate)

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

划重点:Assertion 在说某个东西是什么。

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

划重点:Predicate 是一个返回 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(pet) 成为了 Type Guard
  pet.swim();
} else {
  pet.fly();
}

思考:不加pet is Fish 会怎样?

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

判别的联合(Discriminated unions)

考虑这个定义:

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

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

有什么问题吗?如果这样呢?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
    // Object is possibly 'undefined'.
  }
} 

于是用非 Null 断言操作符 !

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

舒服!???——还没有——

问题在于 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;                      
  }
}

整理下:

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

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;
  }
}

然后我们增加一个 triangle

interface Triangle {
  kind: "triangle";
  sideLength: number;
}

type Shape = Circle | Square | Triangle;

这个时候因为没有实现 Triangle 的 getArea,因此会报错:Type 'Triangle' is not assignable to type 'never'.

窄化小结

思考:窄化解决了什么问题?——联合类型在使用中根据不同控制条件重定义的问题吗?——更提升对联合类型校验的问题。

思考:in typeof instanceof 中有没有遇到 JS 中没有的关键字?所以结论是什么?——TS 是 JS 的超集,但是 TS 会尽量避免新增特性。as is keyof enum在 JS 中没有。

其他 TS 相关文章链接:

# TypeScript 入门修炼至出关

# 再学Typescript,只为更好的运用它

# TS 泛型通关宝典

# TS 的必会技能,学完这些就可以干活了