TypeScript手册——类型收窄(narrowing)

51 阅读17分钟

免责声明: 本文为翻译自 Typescript 官方手册内容,非原创。版权归原作者所有。

原文来源: Narrowing

翻译者注: 本人在翻译过程中已尽最大努力确保内容的准确性和完整性,但翻译工作难免有疏漏。如读者在阅读中发现任何问题,欢迎提出建议或直接查阅原文以获取最准确的信息。

假设我们有一个名为 padLeft 的函数

function padLeft(padding: number | string, input: string): string {
  throw new Error("尚未实现!");
}

如果 paddingnumber 类型,它表示我们想要在 input 前面添加的空格数。如果 paddingstring,表示要在 input 前面添加 padding。让我们尝试实现当 padLeft 被传递一个用于 paddingnumber 时的逻辑。

function padLeft(padding: number | string, input: string) {
  return " ".repeat(padding) + input;
  // 类型 “string | number” 的参数不能赋给类型 “number” 的参数。  
  // 不能将类型 “string” 分配给类型 “number”。
}

啊哦,我们在 padding 上遇到了一个错误。TypeScript 警告我们,在 repeat 函数中传递的值是 number | string 类型,而该函数只接受 number 类型,这是正确的。换句话说,我们没有明确检查 padding 是否为 number,也没有处理它为 string 的情况,所以让我们正好做这个。

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

如果这看起来像是无趣的 JavaScript 代码,那么这就是我们想要表达的观点。除了我们放置的类型注释之外,这段 TypeScript 代码看起来就像 JavaScript 一样。其想法是,TypeScript 的类型系统旨在使编写典型的 JavaScript 代码变得尽可能容易,而无需过分费力就能获得类型安全。

虽然看起来可能不怎么样,但实际上在幕后发生了很多事情。就像 TypeScript 使用静态类型来分析运行时值一样,它在 JavaScript 的运行时控制流构造上叠加了类型分析,比如 if/else、条件三元运算符、循环、真值检查等,这些都可以影响那些类型。

在我们的 if 检查中,TypeScript 看到 typeof padding === "number" 并将其理解为一种特殊形式的代码,称为类型保护。TypeScript 跟踪程序可能采取的执行路径,以分析给定位置上一个值的最具体可能类型。它查看这些特殊检查(称为类型保护)和赋值,并且将类型细化为比声明更具体的类型的过程被称为收窄(narrowing)。在许多编辑器中,我们可以观察到这些变化的类型,并且我们甚至会在示例中进行观察。

image.png

TypeScript 可以理解几种用于类型收窄(narrowing)的不同的结构。

typeof 类型保护

正如我们所见,JavaScript 支持 typeof 运算符,它可以在运行时提供关于值类型的基本信息。TypeScript 期望此运算符返回一组特定的字符串:

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

就像我们在 padLeft 中看到的,这个运算符在许多 JavaScript 库中经常出现,并且 TypeScript 可以理解它,以在不同分支中收窄类型。

在 TypeScript 中,对 typeof 返回的值进行检查是一种类型保护。因为 TypeScript 编码了 typeof 如何对不同的值进行操作,它知道 JavaScript 中一些奇怪的行为。例如,请注意在上面的列表中,typeof 并没有返回字符串 null。请看下面的示例:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
      // “strs”可能为 “null”。
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

printAll 函数中,我们尝试检查 strs 是否是一个对象,以确定它是否是数组类型(现在可能是强化数组是 JavaScript 中的对象类型的好时机)。但事实证明,在 JavaScript 中,typeof null 实际上是 "object"!这是历史上不幸的意外之一。

有足够经验的用户可能不会感到惊讶,但并非每个人都在 JavaScript 中遇到过这种情况;幸运的是,TypeScript 让我们知道 strs 只被收窄为 string[] | null 而不仅仅是 string[]

这可能是一个很好的过渡,引入我们所说的“真值性”检查。

真值性收窄 (Truthiness narrowing)

你可能在词典里找不到“真值性”这个词,但在 JavaScript 中你绝对会听到它。

在 JavaScript 中,我们可以在条件语句、&&||if语句、布尔取反(!)等地方使用任何表达式。举个例子,if 语句并不要求它的条件一定要是 boolean 类型。

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  
  return "Nobody's here. :(";
}

在 JavaScript 中,像 if 这样的结构首先会将它们的条件“强制转换”为 boolean 以便理解它们,然后根据结果是 true 还是 false 来选择它们的分支。像这样的值:

  • 0
  • NaN
  • ""(空字符串)
  • 0n(0 的 bigint 版本)
  • null
  • undefined

都会被强制转换为 false,而其他值会被强制转换为 true。你可以通过使用 Boolean 函数,或者使用更简短的双重布尔取反来将值强制转换为 boolean 值。(后者的优势在于 TypeScript 会推断出一个精确的字面量布尔类型 true,而第一个则会被推断为 boolean 类型。)

// 这两个都会得到 "true"
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

利用这种行为是相当流行的,尤其是用于防范像 nullundefined 这样的值。作为一个例子,让我们尝试将其用于我们的 printAll 函数。

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

你会注意到,我们通过检查 strs 是否为真值来消除了上面的错误。这至少可以防止我们在运行代码时遇到可怕的错误,比如:

TypeError: null is not iterable

请记住,对原始数据进行真值性检查往往容易出错。举个例子,考虑另一种尝试编写 printAll 的方法。

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  不要这样做!
  //   继续阅读
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

我们将函数的整个主体包裹在一个真值检查中,但这有一个微妙的缺点:我们可能不再正确处理空字符串情况。

在这里,TypeScript 并没有给我们带来任何困扰,但如果你对 JavaScript 不太熟悉,那么这种行为是值得注意的。TypeScript 通常可以帮助你在早期捕捉到 bug,但如果你选择不对一个值做任何处理,它能做的事情就有限了,而且也不能过分规定。如果你愿意,你可以确保通过使用一个代码检查工具(linter)来处理像这样的情况。

关于通过真值性进行收窄的最后一点是,布尔否定与 ! 过滤掉了被否定的分支。

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

等值性收窄 (Equality narrowing)

TypeScript 也使用 switch 语句和等式检查,如 === !== ==!= 来收窄类型。例如:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // 我们现在可以在 'x' 或 'y' 上调用任何 'string' 方法
    x.toUpperCase();
          // (method) String.toUpperCase(): string
    y.toLowerCase();
          // (method) String.toLowerCase(): string
  } else {
    console.log(x);
               // (parameter) x: string | number
    console.log(y);
               // (parameter) y: string | boolean
  }
}

当我们在上述示例中检查 xy 是否相等时,TypeScript 知道它们的类型也必须相等。由于 stringxy 都可能取得的唯一公共类型,所以 TypeScript 知道在第一个分支中,xy 必须是 string

对特定的字面量值进行检查(而不是变量)也是可行的。在我们关于真值性收窄的部分,我们编写了一个 printAll 函数,这个函数容易出错,因为它没有正确处理空字符串。相反,我们可以进行特定检查以阻止 null,并且 TypeScript 仍然正确地从 strs 类型中移除了 null

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 的宽松等式检查,如 ==!= 也得到了正确的收窄。如果你不熟悉,检查某物是否 == null 实际上不仅要检查它是否为 null 值 - 还要检查它是否可能为 undefined。同样适用于 == undefined:它检查一个值是否为 nullundefined

interface Container {
  value: number | null | undefined;
}
 
function multiplyValue(container: Container, factor: number) {
  // 从类型中移除'null'和'undefined'
  if (container.value != null) {
    console.log(container.value);
                           // (property) Container.value: number
 
    // 现在我们可以安全地乘以'container.value'
    container.value *= factor;
  }
}

in 操作符收窄 (The in operator narrowing)

JavaScript 有一个运算符用于确定对象或其原型链是否具有某个名称的属性:即 in 运算符。TypeScript 将此视为一种收窄潜在类型范围的方式。

例如,使用代码:"value" in x。其中 "value" 是一个字符串字面量,x 是联合类型。“true” 分支收窄了具有可选或必需属性 valuex 的类型,而 “false” 分支收窄到具有可选或缺失属性 value 的类型。

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

再次强调,可选属性将存在于收窄范围的双方。例如,人类既可以游泳也可以飞行(有了正确的设备),因此应该在 in 检查的两个分支都出现:

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 收窄 (instanceof narrowing)

JavaScript 有一个运算符用于检查一个值是否是另一个值的“实例”。更具体地说,在 JavaScript 中,x instanceof Foo 会检查 x 的原型链是否包含 Foo.prototype。虽然我们在这里不会深入探讨,但当我们进入类时,你将看到更多关于此的内容,它们对于可以用 new 构造的大多数值仍然很有用。你可能已经猜到了,instanceof 也是一种类型保护,并且 TypeScript 在由 instanceof 保护的分支中进行收窄。

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会查看赋值的右侧,并适当地收窄左侧类型。

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 类型变成了 number,但我们仍然能够将一个 string 赋给 x。这是因为 x 的声明类型 - x 开始时的类型 - 是 string | number,并且可分配性始终根据声明类型进行检查。

如果我们把布尔值赋给 x,由于它不属于声明类型的一部分,所以会看到错误。

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

x = true;
// Type 'boolean' is not assignable to type 'string | number'.
 
console.log(x);
           // let x: string | number

控制流分析 (Control flow analysis)

直到现在,我们已经通过一些基本的示例来了解 TypeScript 如何在特定分支中进行收窄类型。但是,除了从每个变量开始向上查找并在 ifwhile、条件等中寻找类型保护之外,还有更多的事情正在发生。例如

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

padLeft 在其第一个 if 块内返回。TypeScript 能够分析这段代码,并看到在 paddingnumber 的情况下,函数体的其余部分(return padding + input;)是无法触达的。因此,它能够从 padding 的类型中移除 number(从 string | number 收窄为 string)。

基于可达性对代码进行分析被称为控制流分析,TypeScript 使用这种流程分析来收窄它遇到的类型保护和赋值时的类型。当变量被分析时,控制流可以反复地拆开和重新合并,并且可以观察到该变量在每个点上都有不同的类型。

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 构造来处理类型收窄,但是有时候你可能希望对代码中类型的变化有更直接的控制。

要定义一个用户自定义类型保护,我们只需要定义一个其返回类型是类型断言的函数。

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

在这个例子中,pet is Fish 是我们的类型断言。断言采用 parameterName is Type 的形式,其中 parameterName 必须是当前函数签名中的一个参数名。

无论何时,只要用某个变量调用 isFish,如果原始类型兼容,TypeScript 就会将该变量收窄到特定的类型。

// 现在调用 'swim' 和 'fly' 都没问题了。
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

注意,TypeScript 不仅知道在 if 分支中 petFish;它还知道在 else 分支中,你没有 Fish,所以你必须有 Bird

你可以使用类型保护 isFish 来过滤一个由 Fish | Bird 组成的数组,并得到一个由 Fish 组成的数组:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// 或者, 等同地
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// 对于更复杂的示例,断言可能需要重复
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

此外,类可以 使用 this is Type 来收窄它们的类型。

断言函数 (Assertion functions)

类型也可以通过使用断言函数来收窄。

可辨识联合类型 (Discriminated unions)

我们迄今为止看到的大多数示例都集中在收窄像 stringbooleannumber 这样的简单类型的单个变量。虽然这很常见,但在 JavaScript 中,我们大部分时间将处理稍微复杂一些的结构。

出于某种动机,让我们想象一下我们正在尝试对圆形和正方形这样的形状进行编码。圆形记录其半径,而正方形记录其边长。我们将使用一个名为 kind 的字段来标识我们正在处理哪种形状。以下是定义 Shape 的初步尝试。

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

注意,我们使用了字符串字面量类型的联合类型:"circle""square" 告诉我们应该将形状视为圆形还是方形。通过使用 "circle" | "square" 而不是 string,我们可以避免拼写错误的问题。

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // This comparison appears to be unintentional
    // because the types '"circle" | "square"' and '"rect"' have no overlap.
    // ...
  }
}

我们可以编写一个 getArea 函数,根据处理的是圆形还是正方形来应用正确的逻辑。我们首先尝试处理圆形。

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

strictNullChecks 下,这会给我们一个错误 - 这是合适的,因为 radius 可能没有被定义。但是,如果我们对 kind 属性进行了适当的检查呢?

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

呃... TypeScript 在这里仍然不知道该怎么做。我们已经达到了一个点,我们对我们的值的了解比类型检查器更多。我们可以尝试使用非空断言(在 shape.radius 后面加一个 !)来表明 radius 肯定存在。

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

但是这种方式并不理想。我们不得不在类型检查器中使用非空断言 (!) 来强制它相信 shape.radius 是定义过的,但是如果我们开始移动代码,这些断言很容易出错。此外,在 strictNullChecks 之外,我们仍然可以无意中访问任何这些字段(因为可选属性在读取时被默认为始终存在)。我们肯定可以做得更好。

这种 Shape 的编码方式的问题在于,类型检查器无法根据 kind 属性判断 radiussideLength 是否存在。我们需要将我们所了解的信息传达给类型检查器。考虑到这一点,让我们再次尝试定义 Shape

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

在这里,我们已经将 Shape 适当地分离成两种类型,并为 kind 属性赋予了不同的值,但在各自的类型中,radiussideLength 被声明为必需的属性。

让我们看一下,当我们试图访问一个 Shaperadius 时会发生什么。

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'.
}

就像我们首次定义 Shape 一样,这仍然是一个错误。当 radius 是可选的,我们得到了一个错误(启用了 strictNullChecks),因为 TypeScript 无法确定该属性是否存在。现在 Shape 是一个联合类型,TypeScript 告诉我们 shape 可能是一个 Square,而 Square 上并没有定义 radius!两种解释都是正确的,但只有 Shape 的联合编码方式会导致无论如何配置 strictNullChecks 都会引发错误。

那么,如果我们再次尝试检查 kind 属性会怎么样呢?

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

这消除了错误!当联合类型中的每个类型都包含一个具有字面类型的共同属性时,TypeScript 会将其视为可辨识的联合,并能够过滤出联合成员。

在这个示例下,kind 是共有属性(也被认为是 Shape 的区分属性)。检查 kind 属性是否为 "circle" 会排除 Shape 中所有没有类型为 "circle"kind 属性的类型。这使得 shape 被收窄到 Circle 类型。

同样的检查也适用于 switch 语句。现在我们可以尝试编写完整的 getArea 函数,而无需烦人的非空断言(!)。

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

这里面重要的是对 Shape 的编码。向 TypeScript 传递正确的信息 - CircleSquare 实际上是两种具有特定 kind 字段的不同类型 - 是至关重要的。做到这一点,我们就可以编写看起来与我们原本会写的 JavaScript 代码无异的类型安全的 TypeScript 代码。从那里开始,类型系统能够做出"正确的"事情,找出我们 switch 语句中每个分支的类型。

顺便说一下,尝试修改上述示例并删除一些return关键字。你会看到,类型检查可以帮助避免在一个 switch 语句中不小心跨越不同子句时产生的错误。

可辨识联合类型不仅仅用于描述圆形和方形。它们非常适合在 JavaScript 中表示任何类型的消息方案,例如,在网络上发送消息(客户端/服务器通信),或者在状态管理框架中编码状态变更(mutations)。

never 类型

在类型收窄时,你可以减少联合类型的可能性,直到排除了所有可能性,没有任何选项剩下。在这些情况下,TypeScript 会使用 never 类型来表示一个不应该存在的状态。

穷尽性检查 (Exhaustiveness checking)

never 类型可以被赋值给任何类型;但是,除了 never 自身之外,没有任何类型可以被赋值给 never。这意味着你可以通过收窄(narrowing)变量类型,然后依赖 never 的出现来在 switch 语句中实行穷尽性检查。

例如,当我们处理了所有可能的情况时,在我们的 getArea 函数中添加一个 default 分支,尝试将 shape 赋值为 never 类型,这不会引发错误。

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

Shape 联合类型添加新成员,将会导致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;
  }
}