# 类型缩小

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

function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

如果 paddingnumber,它会将其视为我们想要添加到 input的空格数。如果 paddingstring,它应该只是将 padding前置到 input。让我们尝试实现当 padLeftpadding传递 number时的逻辑。

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

哦,我们在 padding上遇到错误。TypeScript 警告我们,将 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 遵循我们的程序可以采用的可能执行路径来分析给定位置的值的最具体的可能类型。它着眼于这些特殊检查(称为类型保护)和赋值,将类型精炼为比声明的更具体的类型的过程称为缩小。在许多编辑器中,我们可以观察这些类型的变化,我们甚至会在示例中这样做。

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

TypeScript 可以理解几种不同的结构来缩小作用域。

# 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) {
      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" 检查的一个很好的转义。

# 真实性缩小

真实性可能不是您在字典中可以找到的词,但您会在 JavaScript 中听到很多东西。

在 JavaScript 中,我们可以在条件、&&s、||s、if语句、布尔否定 (!) 等中使用任何表达式。例如,if语句不希望它们的条件总是具有 boolean类型。

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

在 JavaScript 中,像 if这样的构造首先将它们的条件 "coerce" 到 boolean以理解它们,然后根据结果是 true还是 false来选择它们的分支。像这样的值

  • 0
  • NaN
  • ""(空字符串)
  • 0nbigint版零)
  • null
  • undefined

所有强制为 false,其他值强制为 true。您始终可以通过 Boolean函数或使用较短的双布尔否定来将值强制为 boolean。(后者的优点是 TypeScript 推断出一个狭窄的字面布尔类型 true,而将第一个推断为类型 boolean。)

// both of these result in '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) {
  // !!!!!!!!!!!!!!!!
  //  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);
    }
  }
}

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

TypeScript 在这里根本不会伤害我们,但如果您对 JavaScript 不太熟悉,这是值得注意的行为。TypeScript 通常可以帮助您及早发现错误,但如果您选择对值不做任何事情,那么它可以做的事情就只有这么多,而不会过于规范。如果您愿意,您可以确保使用 linter 处理此类情况。

关于真实性缩小的最后一句话是带有 !的布尔否定从否定分支中过滤掉。

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

# 相等性缩小

TypeScript 还使用 switch语句和 ===!====!=等相等性检查来缩小类型。例如:

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);
    console.log(y);
  }
}

当我们在上面的示例中检查 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) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

JavaScript 对 ==!=的更宽松的相等性检查也正确地缩小了作用域。如果您不熟悉,检查某事 == null是否实际上不仅检查它是否具体是值 null- 它还检查它是否可能是 undefined。这同样适用于 == undefined:它检查一个值是 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);

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

# in 运算符缩小

JavaScript 有一个运算符,用于确定对象是否具有带名称的属性:in运算符。TypeScript 将这一点视为缩小潜在类型的一种方式。

例如,使用代码:"value" in x。其中 "value"是字符串字面,x是联合类型。"true" 分支缩小了 x具有可选或必需属性 value的类型,而 "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;
  } else {
    animal;
  }
}

# instanceof 缩小

JavaScript 有一个运算符用于检查一个值是否是另一个值的 "instance"。更具体地说,在 JavaScript 中,x instanceof Foo检查 x的原型链是否包含 Foo.prototype。虽然我们不会在这里深入探讨,并且当我们进入课程时您会看到更多内容,但它们对于可以使用 new构造的大多数值仍然很有用。你可能已经猜到了,instanceof也是一个类型保护,TypeScript 缩小了由 X​​X2 保护的分支。

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

# 赋值

正如我们前面提到的,当我们为任何变量赋值时,TypeScript 会查看赋值的右侧并适当地缩小左侧。

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

console.log(x);
x = "goodbye!";

console.log(x);

请注意,这些分配中的每一个都是有效的。即使在我们第一次分配后观察到的 x类型更改为 number,我们仍然能够将 string分配给 x。这是因为 x的声明类型 - x开始的类型 - 是 string | number,并且始终根据声明的类型检查可分配性。

如果我们将 boolean分配给 x,我们会看到一个错误,因为它不是声明类型的一部分。

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

console.log(x);
x = true;

console.log(x);

# 控制流分析

到目前为止,我们已经通过一些基本示例来了解 TypeScript 如何在特定分支中缩小作用域。但是除了从每个变量中走出来并在 ifs、whiles、条件等中寻找类型保护之外,还有更多的事情要做。例如

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

  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
  } else {
    x = 100;
    console.log(x);
  }

  return x;
}

# 使用类型谓词

到目前为止,我们已经使用现有的 JavaScript 结构来处理缩小作用域,但是有时您希望更直接地控制类型在整个代码中的变化方式。

要定义用户定义的类型保护,我们只需要定义一个返回类型为类型谓词的函数:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
declare function getSmallPet(): Fish | Bird;
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

pet is Fish是本例中的类型谓词。谓词采用 parameterName is Type的形式,其中 parameterName必须是当前函数签名中的参数名称。

任何时候使用某个变量调用 isFish时,如果原始类型兼容,TypeScript 就会将该变量缩小到该特定类型。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
declare function getSmallPet(): Fish | Bird;
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}
// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();

if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

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

您可以使用类型保护 isFish过滤 Fish | Bird的数组并获得 Fish的数组:

type Fish = { swim: () => void; name: string };
type Bird = { fly: () => void; name: string };
declare function getSmallPet(): Fish | Bird;
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}
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);
});

另外,类可以使用 this is Type 来缩小他们的类型。

# 可识别的联合

到目前为止,我们看到的大多数示例都集中在使用简单类型(如 stringbooleannumber)来缩小单个变量的作用域。虽然这很常见,但大多数时候在 JavaScript 中我们将处理稍微复杂的结构。

出于某种动机,假设我们正在尝试对圆形和正方形等形状进行编码。圆记录它们的半径,正方形记录它们的边长。我们将使用一个名为 kind的字段来判断我们正在处理的形状。这是定义 Shape的第一次尝试。

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

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

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

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // ...
  }
}

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

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

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

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

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

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

嗯,TypeScript 还是不知道在这里做什么。我们已经达到了比类型检查器更了解我们的值的地步。我们可以尝试使用非空断言(shape.radius之后的 !)来表示 radius肯定存在。

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

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时会发生什么。

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

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

type Shape = Circle | Square;

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

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

但是,如果我们再次尝试检查 kind属性会怎样?

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

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

type Shape = Circle | Square;

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

这摆脱了错误!当联合中的每个类型都包含具有字面类型的公共属性时,TypeScript 认为这是一个可区分的联合,并且可以缩小联合的成员作用域。

在这种情况下,kind是该公共属性(这被认为是 Shape的判别属性)。检查 kind属性是否为 "circle"删除了 Shape中没有 "circle"类型的 kind属性的所有类型。这将 shape缩小到 Circle类型。

同样的检查也适用于 switch语句。现在我们可以尝试编写完整的 getArea而不需要任何讨厌的 !非空断言。

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

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

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

这里重要的是Shape的编码。向 TypeScript 传达正确的信息 - CircleSquare实际上是具有特定 kind字段的两种不同类型 - 至关重要。这样做可以让我们编写类型安全的 TypeScript 代码,看起来与我们原本编写的 JavaScript 没有什么不同。从那里,类型系统能够做 "right" 的事情并找出我们 switch语句的每个分支中的类型。

顺便说一句,尝试使用上面的示例并删除一些返回关键字。您会看到类型检查有助于避免在 switch语句中意外遇到不同子句时出现错误。

有区别的联合不仅仅用于讨论圆形和正方形。它们非常适合在 JavaScript 中表示任何类型的消息传递方案,例如通过网络发送消息(客户端/服务器通信)或在状态管理框架中编码突变。

# never 型

缩小作用域时,您可以将联合的选项减少到您已消除所有可能性并且一无所有的程度。在这些情况下,TypeScript 将使用 never类型来表示不应该存在的状态。

# 穷举检查

never类型可分配给每种类型;但是,never不能分配任何类型(never本身除外)。这意味着您可以使用缩小并依靠 never出现在 switch 语句中进行详尽的检查。

例如,在尝试将形状分配给 nevergetArea函数中添加 default将在未处理所有可能的情况时引发。

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

interface Square {
  kind: "square";
  sideLength: number;
}
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;
  }
}

Shapeunion 添加新成员,将导致 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 _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}
Last Updated: 5/25/2023, 2:35:11 PM