Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typescript 4.2英文文档 - Narrowing #37

Open
qunzi0214 opened this issue May 28, 2021 · 0 comments
Open

Typescript 4.2英文文档 - Narrowing #37

qunzi0214 opened this issue May 28, 2021 · 0 comments
Labels
read book 读书笔记

Comments

@qunzi0214
Copy link
Owner

qunzi0214 commented May 28, 2021

想象下有个函数 padLeft,它要实现的功能是:如果参数 paddingnumber 类型,表示会在参数 input 前增加多少个空格。如果参数 paddingstring 类型,直接加在参数 input 前:

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

Typescript 报错,并提示 number 类型和 number | string 类型相加可能不是我们想要的结果。因此需要在使用前显式的检测参数 padding 是不是 number 类型:

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

Typescript 会分析运行时代码并和静态类型结合:if/else 结构的控制流、三目表达式、循环等等。在上面的例子中,Typescript 在 if 语句中看到 typeof padding === "number" 并了解到这是一种叫 type guard 的特殊形式代码,并根据程序可能的执行路径分析出一个更具体的类型。类似这种特殊的检查被称为 Narrowing

typeof type guards

在 Javascript 中,typeof 操作符会在运行时返回操作值的基本类型信息字符串:

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

Typescript 甚至兼容了许多 Javascript 的“怪癖”,例如对 null 使用 typeof 操作符会返回 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
  }
}

在这个例子中,strs 仅仅被 narrow 至类型 string[] | null 而不是 string[],因此 Typescript 会报错

Truthiness narrowing

在 Javascript 中,可以在条件判断语句( if && || ! )中使用任意表达式,例如 if 语句并不要求括号中必须是 boolean 类型,它会将括号中的值/语句强制类型转化为 boolean 类型的值去做判断。以下这些值会被转换为 false

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

而其他值都会被转换为 true。始终可以通过 Boolean 函数或者双重否定表达式 !! 来将某个值转化为 boolean 类型的值(需要注意的是,前者在 Typescript 中会被类型推论为 boolean,而后者会被推论为一个字面量布尔类型 true ?)

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world";        // type: true,    value: true

这里自己测试结果是都推论为布尔类型,双重否定并没有推论为字面量布尔类型

修改上面的 printAll 函数,加入 truthiness narrowing,会发现 Typescript 的报错消失了:

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

需要记住,对基本类型做真实性检查通常会引发错误,考虑以下代码:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

这里用一个真实性检查包裹了整个函数体,但是有一个微妙的问题:忽略了处理空字符串场景

对于不熟悉 Javascript 的人来说,这是需要注意的情况。Typescript 可以帮助我们更早的捕获错误,但是如果我们对某个值不做任何操作,它仅能在不过度规定的情况下做这么多(没看懂,个人理解是处理不了这种情况)。如果需要,可以使用类似 linter 这样的工具

最后一点关于 Truthiness narrowing,布尔否定 ! 时,Typescript 会从否定分支进行 narrowing

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

Equality narrowing

Typescript 同样会使用 switch 语句,相等检查例如 === !== == != 来进行 narrowing

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // We can now call any 'string' method on 'x' or 'y'.
    x.toUpperCase();
    y.toLowerCase();
  } else {
    console.log(x);
    // (parameter) x: string | number
    console.log(y);
    // (parameter) y: string | boolean
  }
}

以上代码中,当我们确保 xy 值和类型都相同时,Typescript 会发现它们只有一个公用类型 string,所以在此分支中,可以直接使用任意字符串方法

检查是否特定类型的字面量也是生效的,考虑如下代码,通过特定检查,Typescript 正确的将 null 类型从 strs 上过滤掉了:

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

Javascript 的宽松相等 == != 在 Typescript 中也可以正确的 narrowing。在宽松相等中,检查某个值是否 null 不仅仅是特定值 null 本身,同样也检查是否是 undefined,反之亦然(不管怎么说还是不要用宽松相等了...):

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

function multiplyValue(container: Container, factor: number) {
  // Remove both 'null' and 'undefined' from the type.
  if (container.value != null) {
    console.log(container.value);
    // (property) Container.value: number

    // Now we can safely multiply 'container.value'.
    container.value *= factor;
  }
}

The in operator narrowing

在 Javascript 中,可以通过 in 操作符来判断某个属性是否存在一个对象中。Typescript 可以通过这种方式来 narrowing

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

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

  return animal.fly();
  // (parameter) animal: Bird
}

需要注意的是,可选属性在 narrowing 时,会同时存在于两边:

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

function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) { 
    animal
    // (parameter) animal: Fish | Human
  } else {
    animal
    // (parameter) animal: Bird | Human
  }
}

instanceof narrowing

在 Javascript 中,x instanceof Foo 可以检查 Foo.prototype 是否存在于 x 的原型链中。同样的,Typescript 中可以通过 instanceofnarrowing

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

Assignments

当我们给一个变量赋值时,Typescript 会分析赋值表达式右侧,并 narrowing 合适的左侧类型:

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

x = 1;
console.log(x);
// let x: number

x = "goodbye!";
console.log(x);
// let x: string

x = true
// Type 'boolean' is not assignable to type 'string | number'.

这里需要注意,尽管在 x = 1 这个赋值表达式之后,可以观察到 x 的类型为 number ,但仍旧可以将 string 类型的值赋予 x 。这是因为 x 的声明类型(即最初的类型)是 string | number

Control flow analysis

基于“流程上是否可到达”的分析也被称作控制流分析。Typescript 会根据控制流 narrowing 类型 — 当变量遇到类型守卫或赋值表达式。当一个变量被分析时,控制流可能一次又一次的拆分或重新合并,在每一个拆分点或合并点,变量都会被观测到不同的类型:

function example() {
  let x: string | number | boolean;
  x = Math.random() < 0.5;
  console.log(x);
  // let x: boolean

  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
    // let x: string
  } else {
    x = 100;
    console.log(x);
    // let x: number
  }

  return x;
  // let x: string | number
}

Using type predicates

截至目前,我们已经通过 Javascript 的结构来处理 narrowing,但是有时候我们可能想要更直接的在代码中操作类型变化

想要定义一个自定义 type guard,仅需要简单的定义一个返回值是 type predicate 的函数:

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

此例中,pet is Fish 就是 type predicate(类型谓语?) ,类型谓语总是 parameterName is Type 这种形式( parameterName 必须存在于当前函数签名中)

任何时候,当 isFish 被调用,Typescript 都可以进行 narrowing

let pet = getSmallPet();
// let pet: Fish | Bird

if (isFish(pet)) {
  pet.swim();
  // let pet: Fish
} else {
  pet.fly();
  // let pet: Bird
}

也可以通过类型守卫 isFish 来过滤一个 Fish | Bird 数组,获得一个 Fish 数组:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];

// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

Discriminated unions

思考如下代码,定义一个 Shape 接口,kind 字段是一个联合字面量类型(防止拼写错误)。同时,当图形为圆时,我们关注的是 radius 字段,当图形为正方形时,则是 sideLength ,因此这两个字段是可选的:

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

当我们想要编写一个 getArea 函数(基于是圆形还是正方形计算面积):

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

如果设置了 strictNullChecks 配置项,会获得一个错误,radius 可能是没有定义的。如果我们尝试通过 kind 字段来优化这个问题呢?

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

Typescript 依旧不会理解这里到底要做什么。在此处,我们对于类型的了解比 Typescript 要多,可以尝试用 non-null 断言告诉 Typescript radius 一定存在:

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

但是这种方式仍然不理想且容易出错。另外,如果不设置 strictNullChecks,虽然可以随意访问任何属性,但是也同样极容易导致错误(可选属性会被假设为始终存在)

问题在于类型检查器没有任何途径根据 kind 属性知道 radiussideLength 是否存在,考虑到这个问题,可以换一种方式来定义 Shape

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

type Shape = Circle | Square;

这里,我们拆分了 Shape 变成两个类型,同时 radiussideLength 不再是可选属性,而是必选属性。此时如果访问 Shape 类型的 radius 属性:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
  // Property 'radius' does not exist on type 'Shape'.
  // Property 'radius' does not exist on type 'Square'.
}

当我们使用可选属性时,Typescript 仅能警告这条属性可能不存在。当联合两个接口时,Typescript 会告诉我们 Shape 有可能是 Square,而 Square 上不存在 radius 属性!

与此同时,如果再次尝试检查 kind 属性:

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

错误消失了!当一个联合类型中的所有成员都有同一个属性时(此属性必须是字面量类型),Typescript 会认为这是一个 discriminated union,并可以根据此属性来 narrow 联合类型的成员,此例中 kind 会被认为是 Shape 的“区别属性”。同样的,switch 语句也可以正常生效:

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

The never type

如果我们通过 narrowing,在某个点排除了所有可能的类型,那么此时,Typescript 会使用 never 类型来表示这种不应该不存在的状态

Exhaustiveness checking

never 类型可以赋予任何类型,但是任何类型都不能赋予 never 类型(除了 never 本身),可以依靠这个特性,在 switch 语句中做一个是否穷尽判断:

type Shape = Circle | Square;

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

如果在此时给联合类型新增一个成员,Typescript 会报错:

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 _exhaustiveCheck: never = shape;
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
read book 读书笔记
Projects
None yet
Development

No branches or pull requests

1 participant