对象、函数与类型收窄/类型收缩/类型窄化

456 阅读5分钟

本文翻译自 Objects, Functions, and Type Narrowing

TypeScript 的类型收窄功能非常强大,可以让它在代码的某些地方推断出更具体的类型。 例如,TypeScript 能够理解在以下 if 语句中,fruit 变量必须是字面值 "apple"

// @lib: dom,esnext
const fruit = Math.random() > 0.5 ? "apple" : undefined;

fruit;
// "apple" | undefined

if (fruit) {
  fruit;
  // "apple"
}

但是,TypeScript 的类型系统并不完美。 在某些情况下,TypeScript 无法准确地收窄类型,达不到你期望的效果。

看看这个代码片段,其中 counts.apple 被推断为类型 number

const counts = {
  apple: 1,
};

counts.apple;
//     number

虽然 counts 的类型是 { apple: number },但 TypeScript 不应该确定 counts.apple 的当前值具体是字面量 1,而不是一般的原始类型 number 吗? TypeScript 不能搞清楚我们还没有更改这个值吗?

它可以,但它不会。 原因非常充分。

回顾:字面量与原始类型

TypeScript 区分以下两种类型分类:

  • 原始类型:JavaScript 中的基本非对象值类型,如 booleanstringnumber
  • 字面量:原始类型中的具体值,如 false"apple"1

默认情况下,对于变量的初始值,TypeScript 会推断对象属性为它们的一般原始类型,而不是具体的字面量类型。

跟踪变更是不切实际的

那么,为什么 TypeScript 即使在对象使用之前,也将对象属性推断为原始类型呢?

原因是 TypeScript 很难——通常是不可能——知道对象在初始化和后续使用之间是否已被修改。 如果对象在任何有意义的应用程序逻辑中使用,TypeScript 通常无法判断对象在该逻辑中是否被修改。

一个实际的例子

假设你想在使用之前打印字符串和 counts 对象。 你编写了一个只调用 console.log 的自定义 log 函数。 你还是希望 counts.apple1,对吧?

// @lib: dom,esnext
function log(data: unknown) {
  console.log("Logging:", data);
}

const counts = {
  apple: 1,
};

log(counts);

counts.apple;
//     number

遗憾的是,TypeScript 通常无法合理判断函数调用是否会修改其参数的属性。 函数可能会调用许多其他函数,包括在 .d.ts 文件中声明的函数,TypeScript 无法访问其实现。

因此,TypeScript 必须假定函数调用 可能 会修改作为参数提供的对象的属性。

理论上,log 函数可以在其 data 参数上使用 readonly 修饰符,以表明它不会更改 data。 遗憾的是,许多内置和用户类型定义没有正确地将参数标记为只读。

可预测性是关键

因为 TypeScript 必须假定函数调用可能会修改作为参数提供的对象的属性,所以任何对对象初始类型收窄在对象使用后的任何函数调用后都将不再适用。 这种限制可能会让开发人员感到困惑,因为他们期望在调用看似无害的函数(如 console.log)之后,属性仍然保持收窄。

在以下代码片段中,即使 TypeScript 在 console.log(counts) 之前已将 counts.apple 收窄为 1,在函数调用之后,它也必须忘记这一点:

// @lib: dom,esnext
const counts = {
  apple: 1,
};

counts.apple;
//     number

console.log(counts);

counts.apple;
//     number

如果开发人员看到 counts.apple 在函数调用前后推断的类型不同,他们会感到非常惊讶。 TypeScript 更倾向于保持它们一致。

类型收窄已经过于乐观

到目前为止,你可能对 TypeScript 有时的类型收窄过于保守感到不满。 但是:在其他情况下,类型收窄实际上过于激进!

在这个代码片段中,counts.appleif 语句内被收窄为 1,即使在调用 logAndMutate(counts) 并更改 data.apple 属性之后也是如此:

// @lib: dom,esnext
function logAndMutate(data: typeof counts) {
  console.log("Logging:", data);
  data.apple += 1;
}

const counts = {
  apple: 1,
};

if (counts.apple === 1) {
  counts.apple;
  //     1

  logAndMutate(counts);

  counts.apple;
  //     1
}

函数调用不会重置显式类型收窄是 TypeScript 类型系统的一个特定特性。 虽然这个特性并不总是正确的行为(看起来与本文第一部分的设计选择相反),但它在大多数代码中通常是方便且正确的。

函数声明与类型收窄

有趣的是,虽然调用函数可能不会从作为参数提供的对象中移除类型收窄,但函数体通常会从值中移除类型收窄。 唯一的例外是 IIFE(立即调用函数表达式)或声明后立即调用的函数——TypeScript 理解它们只运行一次并且不会在以后使用。

在这个代码片段中,withCountsDeclaration 函数体忘记了 counts.apple 被收窄为 1,但 withCountsIIFE 函数体保留了类型收窄:

// @lib: dom,esnext
const counts = {
  apple: 1,
};

if (counts.apple === 1) {
  counts.apple;
  //     1

  function withCountsDeclaration() {
    counts.apple;
    //     number
  }

  (function withCountsIIFE() {
    counts.apple;
    //     1
  })();
}

虽然有时函数体丢失类型收窄可能会令人恼火,但这种丢失是一个很好的安全措施。 这些函数可能会在某个时候调用,在类型收窄不再有效之后。

在这个例子中,由于 runWithMaybeValue 函数将在 maybeValue 被设置为 undefined 后一秒钟被调用,因此它不应假定 maybeValue 仍然被收窄为 string

// @lib: dom,esnext
let maybeValue = Math.random() > 0.5 ? "cherry" : undefined;

if (maybeValue) {
  console.log(maybeValue);
  //          string

  function runWithMaybeValue() {
    console.log(maybeValue);
    //          string | undefined
  }

  setTimeout(runWithMaybeValue, 1000);
}

maybeValue = undefined;

在这种情况下,TypeScript 没有在函数体内应用类型收窄是件好事。 TypeScript 的类型系统几乎不可能理解函数参数何时是立即调用的函数或延迟调用的函数。

结论

TypeScript 关于何时收窄对象类型或不收窄对象类型的特性一开始可能会令人困惑。 但是,如果你理解了背后的原因,它们是可以预测的。

总结:

  • 变量对象属性不会立即从原始类型收窄为字面量
  • 函数 调用 不会 重置显式应用于值的类型收窄
  • 函数 声明 重置显式应用于值的任何类型收窄

这三条规则基于大多数现实世界 JavaScript 代码的操作方式。

进一步阅读

TypeScript 仓库中存在一个传奇的 控制流分析中的权衡 问题,供那些想深入了解的人阅读。 该问题描述了 TypeScript 类型检查器如何分析值流的许多权衡。