【翻译】TypeScript: Documentation - Narrowing

文章介绍了TypeScript如何通过类型保护(如typeof检查、相等性检查、真实性检查和in运算符)以及控制流分析来实现类型缩小,确保类型安全。重点讨论了如何在函数中处理number|string类型的输入,并展示了如何通过条件检查和赋值来调整变量的类型。此外,还提到了never类型和全面性检查在确保所有可能情况都被处理时的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

缩小类型

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

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

如果 padding 是一个number,它将把它当作我们想要在 input 前面添加的空格数。如果 padding 是一个string,它应该直接将 padding 添加到 input 前面。让我们尝试实现当 padLeft 传递一个 number 作为 padding 时的逻辑。

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

Uh-oh,我们在 padding 上遇到了一个错误。TypeScript 警告我们,我们正在将一个类型为 number | string 的值传递给 repeat 函数,而它只接受number,它是对的。换句话说,我们没有先明确检查 padding 是否为数字,也没有处理它是string的情况,所以让我们来做到这一点。

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

如果这看起来大多是不感兴趣的 JavaScript 代码,那就是这个意思。除了我们放置的注释(annotations)之外,这段 TypeScript 代码看起来就像 JavaScript。其思想是 TypeScript 的类型系统旨在尽可能容易地编写典型的 JavaScript 代码,而无需为获得类型安全而过分努力。

虽然它看起来不像什么,但实际上这里有很多东西。就像 TypeScript 使用静态类型分析运行时值一样,它在 JavaScript 的运行时控制流构造(如 if/else、条件三元运算符、循环、真实性检查等)上覆盖了类型分析,这些都会影响那些类型。

这意味着 TypeScript 能够根据您的代码中的控制流结构(如 if/else、条件三元运算符、循环等)来推断变量的确切类型。

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

TypeScript 了解几种不同的用于缩小类型的构造。

这句话指出 TypeScript 提供了几种不同的方法来缩小类型

typeof type guards

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") {
    // 'strs' 可能是 'null'.
    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)检查。

Truthiness narrowing

真实性缩小类型

真实性(truthiness)可能不是您会在字典中找到的单词,但它在 JavaScript 中非常常见。

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

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

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

  • 0
  • NaN
  • ""
  • 0n(bigint 中的零)
  • null
  • undefined

所有这些值都强制转换为 false,其他值强制转换为 true。您可以通过将它们通过 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 通常可以帮助您提前发现错误,但如果您选择对一个值不做任何处理,它只能做到这么多而不会过于规定。如果您愿意,您可以使用 linter 来确保处理这些情况。

关于通过真实性缩小类型的最后一句话是,带有 ! 的布尔取反会从取反的分支中过滤掉。

TypeScript 会根据取反的结果来缩小变量的类型。

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.toUpperCase();
    y.toLowerCase();
  } else {
    console.log(x);
    console.log(y);
  }
}

在上面的例子中,当我们检查 xy 是否相等时,TypeScript 知道它们的类型也必须相等。由于字符串是 xy 都可以采用的唯一公共类型,因此 TypeScript 知道 xy 在第一个分支中必须是字符串。

特定字面值的检查(与变量相对)也是有效的。在我们关于真值缩小的部分中,我们编写了一个名为 printAll 的函数,但它容易出错,因为它意外地没有正确处理空字符串。相反,我们可以进行特定的检查来排除空值,并且TypeScript仍然会正确地从 strs 的类型中移除空值。

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 也是如此:它检查一个值是否为 nullundefined

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

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

The in operator narrowing

in运算符缩小

JavaScript有一个运算符用于检查一个对象是否具有指定名称的属性:in 运算符。 TypeScript 将其视为一种缩小潜在类型的方法。

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

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

  return animal.fly();
}

重申可选属性将在缩小的两侧都存在,例如,人类可以游泳和飞行(使用正确的设备),因此应该在 in 检查的两侧都显示。

TypeScript中,当您使用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 narrowing

instanceof缩小

JavaScript 有一个运算符用于检查一个值是否是另一个值的instance。更具体地说,在 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中为任何变量赋值时,TypeScript会查看赋值语句的右侧,并相应地缩小左侧的类型范围。

TypeScript中,当我们给变量赋值时,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,但我们仍然能够将一个字符串赋给 x。这是因为变量 x 声明的类型 - 即 x 最初的类型 - 是 string | number,而可赋值性总是针对声明的类型进行检查。

如果我们将一个布尔值赋给 x,我们将会看到一个错误,因为布尔值不是声明的类型的一部分。

可赋值性: 赋值的可行性是根据变量声明时的类型来判断的。

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 能够分析这段代码,并看到在 padding 是数字的情况下,其余的主体(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 构造来处理缩小,但有时你希望更直接地控制类型在代码中的变化。

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

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 分支中 pet 是一条鱼;它还知道在 else 分支中,你没有鱼,所以你一定有一只鸟。

你可以使用类型保护 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);
});

Discriminated unions

区分联合类型

到目前为止,我们看到的大多数示例都集中在对具有简单类型(如字符串、布尔值和数字)的单个变量进行缩小。虽然这很常见,但在JavaScript中,我们通常会处理稍微更复杂的结构。

为了有所启发,让我们想象一下我们正在尝试编码像圆和正方形这样的形状。圆保持其半径的跟踪,而正方形保持其边长的跟踪。我们将使用一个称为“kind”的字段来告诉我们正在处理哪种形状。下面是对Shape的第一个尝试定义。

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

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

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // 这个比较似乎是无意的,因为类型 '"circle" | "square"' 和 '"rect"' 没有重叠部分。
    // ...
  }
}

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

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

在严格的空值检查(strictNullChecks)下,这会导致一个错误,这是合理的,因为半径可能没有定义。但是,如果我们对kind属性进行适当的检查呢?

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

Hmm,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,而Squares上没有定义radius属性!这两种解释都是正确的,但只有将 Shape 编码为联合类型时,无论 strictNullChecks 如何配置,都会导致错误。

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

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

这样就消除了错误!当联合类型中的每个类型都包含具有文字类型的公共属性时,TypeScript 认为这是一个判别联合类型(discriminated union),并可以缩小联合类型的成员。

在这种情况下,kind就是那个共同属性(也就是Shape的判别属性)。检查kind属性是否为"circle"可以消除Shape中没有kind属性类型为"circle"的所有类型。这将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字段的两种不同类型——是至关重要的。这样做可以让我们编写类型安全的TypeScript代码,其外观与我们原本编写的JavaScript代码没有任何区别。从那里开始,类型系统能够做出“正确”的事情,并确定我们 switch 语句中每个分支的类型。

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

The never type

never 类型

在进行缩小类型时,您可以将联合类型的选项减少到仅剩下没有任何可能性的情况。在这种情况下,TypeScript将使用never类型来表示不应存在的状态。

Exhaustiveness checking

全面性检查

never类型可以分配给任何类型;然而,没有任何类型可以分配给never(除了never本身)。这意味着您可以使用类型缩小并依赖于never的出现在switch语句中进行全面性检查。

例如,在我们的getArea函数中添加一个默认情况,尝试将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;
  }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值