Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

TypeScript 4.9

satisfies 运算符

TypeScript 开发者经常面临一个困境:我们想要确保某个表达式匹配某个类型,但同时希望保留该表达式的最具体类型以用于推断目的。

例如:

ts
// 每个属性可以是字符串或 RGB 元组。
const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    bleu: [0, 0, 255]
//  ^^^^ 糟了——我们打错字了!
};

// 我们希望能够对 'green' 使用字符串方法...
const greenNormalized = palette.green.toUpperCase();

注意我们写了 bleu,而本应是 blue。 我们可以尝试通过对 palette 使用类型注解来捕获 bleu 这个拼写错误,但那样会丢失每个属性的信息。

ts
type Colors = "red" | "green" | "blue";

type RGB = [red: number, green: number, blue: number];

const palette: Record<Colors, string | RGB> = {
    red: [255, 0, 0],
    green: "#00ff00",
    bleu: [0, 0, 255]
//  ~~~~ 现在正确检测到拼写错误
};

// 但我们现在在这里有一个不期望的错误 - 'palette.green' “可能”是 RGB 类型,
// 而属性 'toUpperCase' 在类型 'string | RGB' 上不存在。
const greenNormalized = palette.green.toUpperCase();

新的 satisfies 运算符允许我们验证表达式的类型是否匹配某个类型,而不改变该表达式的结果类型。 例如,我们可以使用 satisfies 来验证 palette 的所有属性是否与 string | number[] 兼容:

ts
type Colors = "red" | "green" | "blue";

type RGB = [red: number, green: number, blue: number];

const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    bleu: [0, 0, 255]
//  ~~~~ 现在捕获了拼写错误!
} satisfies Record<Colors, string | RGB>;

// toUpperCase() 方法仍然可访问!
const greenNormalized = palette.green.toUpperCase();

satisfies 可用于捕获许多可能的错误。 例如,我们可以确保一个对象具有某个类型的所有,但不会更多:

ts
type Colors = "red" | "green" | "blue";

// 确保我们恰好拥有 'Colors' 中的键。
const favoriteColors = {
    "red": "yes",
    "green": false,
    "blue": "kinda",
    "platypus": false
//  ~~~~~~~~~~ 错误 - "platypus" 从未在 'Colors' 中列出。
} satisfies Record<Colors, unknown>;

// 关于 'red'、'green' 和 'blue' 属性的所有信息都被保留。
const g: boolean = favoriteColors.green;

也许我们不关心属性名称是否匹配,但我们关心每个属性的类型。 在这种情况下,我们也可以确保对象的所有属性值都符合某个类型。

ts
type RGB = [red: number, green: number, blue: number];

const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    blue: [0, 0]
    //    ~~~~~~ 错误!
} satisfies Record<string, string | RGB>;

// 每个属性的信息仍然保留。
const redComponent = palette.red.at(0);
const greenNormalized = palette.green.toUpperCase();

有关更多示例,你可以查看提出此功能的 issue实现拉取请求。 我们要感谢 Oleksandr Tarasiuk 与我们共同实现并迭代此功能。

使用 in 运算符对未列出的属性进行收窄

作为开发者,我们经常需要处理在运行时不完全知道的值。 事实上,无论是从服务器获取响应还是读取配置文件,我们常常不知道属性是否存在。 JavaScript 的 in 运算符可以检查对象上是否存在某个属性。

以前,TypeScript 允许我们收窄任何未显式列出属性的类型。

ts
interface RGB {
    red: number;
    green: number;
    blue: number;
}

interface HSV {
    hue: number;
    saturation: number;
    value: number;
}

function setColor(color: RGB | HSV) {
    if ("hue" in color) {
        // 'color' 现在具有类型 HSV
    }
    // ...
}

这里,类型 RGB 没有列出 hue 属性,因此被收窄掉,剩下类型 HSV

但是,如果没有任何类型列出给定属性,那会怎样? 在这些情况下,语言对我们帮助不大。 让我们看下面这个 JavaScript 示例:

js
function tryGetPackageName(context) {
    const packageJSON = context.packageJSON;
    // 检查我们是否有一个对象。
    if (packageJSON && typeof packageJSON === "object") {
        // 检查它是否有一个字符串类型的 name 属性。
        if ("name" in packageJSON && typeof packageJSON.name === "string") {
            return packageJSON.name;
        }
    }

    return undefined;
}

将其重写为规范的 TypeScript 只需要定义并使用 context 的类型; 然而,为 packageJSON 属性选择像 unknown 这样的安全类型会在旧版本的 TypeScript 中导致问题。

ts
interface Context {
    packageJSON: unknown;
}

function tryGetPackageName(context: Context) {
    const packageJSON = context.packageJSON;
    // 检查我们是否有一个对象。
    if (packageJSON && typeof packageJSON === "object") {
        // 检查它是否有一个字符串类型的 name 属性。
        if ("name" in packageJSON && typeof packageJSON.name === "string") {
        //                                              ~~~~
        // 错误!类型 'object' 上不存在属性 'name'。
            return packageJSON.name;
        //                     ~~~~
        // 错误!类型 'object' 上不存在属性 'name'。
        }
    }

    return undefined;
}

这是因为虽然 packageJSON 的类型从 unknown 收窄为 object,但 in 运算符严格收窄为实际定义了被检查属性的类型。 因此,packageJSON 的类型仍然是 object

TypeScript 4.9 使得 in 运算符在收窄根本没有列出属性的类型时变得更强大一些。 语言不再保留原样,而是将它们的类型与 Record<"被检查的属性键", unknown> 相交。

因此,在我们的示例中,packageJSON 的类型将从 unknown 收窄为 object,再收窄为 object & Record<"name", unknown>。 这允许我们直接访问 packageJSON.name 并独立地收窄它。

ts
interface Context {
    packageJSON: unknown;
}

function tryGetPackageName(context: Context): string | undefined {
    const packageJSON = context.packageJSON;
    // 检查我们是否有一个对象。
    if (packageJSON && typeof packageJSON === "object") {
        // 检查它是否有一个字符串类型的 name 属性。
        if ("name" in packageJSON && typeof packageJSON.name === "string") {
            // 现在可以工作了!
            return packageJSON.name;
        }
    }

    return undefined;
}

TypeScript 4.9 还收紧了一些关于 in 如何使用的检查,确保左侧可赋值给类型 string | number | symbol,右侧可赋值给 object。 这有助于检查我们使用的是有效的属性键,而不是意外地检查原始类型。

有关更多信息,请阅读实现拉取请求

类中的自动访问器

TypeScript 4.9 支持 ECMAScript 中即将推出的一个特性,称为自动访问器。 自动访问器的声明方式类似于类上的属性,区别在于它们使用 accessor 关键字声明。

ts
class Person {
    accessor name: string;

    constructor(name: string) {
        this.name = name;
    }
}

在底层,这些自动访问器“脱糖”为一个带有不可访问私有属性的 getset 访问器。

ts
class Person {
    #__name: string;

    get name() {
        return this.#__name;
    }
    set name(value: string) {
        this.#__name = value;
    }

    constructor(name: string) {
        this.name = name;
    }
}

你可以在原始 PR 中阅读更多关于自动访问器拉取请求的信息

NaN 的相等性检查

JavaScript 开发者的一个主要陷阱是使用内置相等运算符检查 NaN 值。

背景知识:NaN 是一个特殊的数值,代表“不是一个数字”。没有任何值等于 NaN——即使是 NaN 本身!

js
console.log(NaN == 0)  // false
console.log(NaN === 0) // false

console.log(NaN == NaN)  // false
console.log(NaN === NaN) // false

但至少对称地,一切总是不等于 NaN

js
console.log(NaN != 0)  // true
console.log(NaN !== 0) // true

console.log(NaN != NaN)  // true
console.log(NaN !== NaN) // true

严格来说,这不是 JavaScript 特有的问题,因为任何包含 IEEE-754 浮点数的语言都有相同的行为; 但 JavaScript 的主要数值类型是浮点数,而 JavaScript 中的数字解析经常会产生 NaN。 因此,检查 NaN 变得相当常见,正确的方法是使用 Number.isNaN ——但正如我们提到的,很多人错误地使用了 someValue === NaN

TypeScript 现在对直接与 NaN 比较会报错,并建议改用某种形式的 Number.isNaN

ts
function validate(someValue: number) {
    return someValue !== NaN;
    //     ~~~~~~~~~~~~~~~~~
    // 错误:此条件将始终返回 'true'。
    //        你的意思是 '!Number.isNaN(someValue)' 吗?
}

我们相信此更改应该有助于捕获初学者的错误,类似于 TypeScript 当前对与对象和数组字面量比较发出错误的方式。

我们要感谢 Oleksandr Tarasiuk 贡献了此检查

文件监视现在使用文件系统事件

在早期版本中,TypeScript 严重依赖轮询来监视单个文件。 使用轮询策略意味着定期检查文件的状态以获取更新。 在 Node.js 中,fs.watchFile 是获取轮询文件监视器的内置方法。 虽然轮询在跨平台和文件系统方面往往更具可预测性,但这意味着即使没有任何更改,你的 CPU 也必须定期中断并检查文件更新。 对于几十个文件,这可能不明显; 但在具有大量文件的大型项目中——或者 node_modules 中有大量文件——这可能会成为资源消耗大户。

一般来说,更好的方法是使用文件系统事件。 我们可以声明对特定文件的更新感兴趣,并在这些文件实际更改时提供回调,而不是轮询。 大多数现代平台都提供诸如 CreateIoCompletionPortkqueueepollinotify 等工具和 API。 Node.js 主要通过提供 fs.watch 来抽象这些。 文件系统事件通常工作得很好,但使用它们以及使用 fs.watch API 存在很多注意事项。 监视器需要小心考虑 inode 监视在某些文件系统上不可用(例如网络文件系统)、是否支持递归文件监视、目录重命名是否会触发事件,甚至文件监视器耗尽! 换句话说,这并非免费午餐,尤其是当你在寻找跨平台的解决方案时。

因此,我们默认选择了最低公共分母:轮询。并非总是如此,但大多数情况下是这样。

随着时间的推移,我们提供了选择其他文件监视策略的方法。 这使我们能够获得反馈,并针对大多数这些特定于平台的陷阱强化了我们的文件监视实现。 随着 TypeScript 需要扩展到更大的代码库,并在这方面有所改进,我们认为将文件系统事件作为默认选项是一项值得的投资。

在 TypeScript 4.9 中,文件监视默认由文件系统事件驱动,只有在无法设置基于事件的监视器时才会回退到轮询。 对于大多数开发者来说,在 --watch 模式下运行,或使用像 Visual Studio 或 VS Code 这样由 TypeScript 驱动的编辑器时,这应该会提供资源占用更低的体验。

文件监视的工作方式仍然可以通过环境变量和 watchOptions 进行配置——并且某些编辑器(如 VS Code)可以独立支持 watchOptions。 使用更特殊设置(如源代码位于网络文件系统(如 NFS 和 SMB)上)的开发者可能需要选择回退到旧的行为;不过,如果服务器有合理的处理能力,启用 SSH 并远程运行 TypeScript 以便直接本地访问文件可能更好。 VS Code 有很多远程扩展可以让这更容易。

你可以在 GitHub 上阅读更多关于此更改的信息

编辑器的“删除未使用的导入”和“排序导入”命令

以前,TypeScript 仅支持两个用于管理导入的编辑器命令。 以我们的示例为例,考虑以下代码:

ts
import { Zebra, Moose, HoneyBadger } from "./zoo";
import { foo, bar } from "./helper";

let x: Moose | HoneyBadger = foo();

第一个命令称为“组织导入”,它会删除未使用的导入,然后对剩余的导入进行排序。 它会将该文件重写为如下所示:

ts
import { foo } from "./helper";
import { HoneyBadger, Moose } from "./zoo";

let x: Moose | HoneyBadger = foo();

在 TypeScript 4.3 中,我们引入了一个名为“排序导入”的命令,它对文件中的导入进行排序,但不删除它们——并会将文件重写为如下形式:

ts
import { bar, foo } from "./helper";
import { HoneyBadger, Moose, Zebra } from "./zoo";

let x: Moose | HoneyBadger = foo();

“排序导入”的一个问题是,在 Visual Studio Code 中,此功能仅作为保存时命令可用——而不是作为手动触发的命令。

TypeScript 4.9 添加了另一半,现在提供了“删除未使用的导入”。 TypeScript 现在将删除未使用的导入名称和语句,但会保持相对顺序不变。

ts
import { Moose, HoneyBadger } from "./zoo";
import { foo } from "./helper";

let x: Moose | HoneyBadger = foo();

此功能可供希望使用任一命令的所有编辑器使用; 但值得注意的是,Visual Studio Code(1.73 及更高版本)将内置支持,并通过其命令面板公开这些命令。 希望使用更细粒度的“删除未使用的导入”或“排序导入”命令的用户,如果需要,可以将“组织导入”的快捷键重新分配给它们。

你可以在此处查看该功能的细节

return 关键字上跳转到定义

在编辑器中,当在 return 关键字上运行跳转到定义时,TypeScript 现在会将你跳转到相应函数的顶部。 这有助于快速了解某个 return 属于哪个函数。

我们期望 TypeScript 将此功能扩展到更多关键字,如 awaityieldswitchcasedefault

此功能已实现,感谢 Oleksandr Tarasiuk

性能改进

TypeScript 有一些虽小但值得注意的性能改进。

首先,TypeScript 的 forEachChild 函数已被重写,使用函数表查找而不是在所有语法节点上使用 switch 语句。 forEachChild 是在编译器中遍历语法节点的主力函数,在编译器的绑定阶段以及语言服务的部分中大量使用。 forEachChild 的重构使绑定阶段和语言服务操作所花费的时间减少了高达 20%。

当我们发现 forEachChild 的性能提升后,我们在 visitEachChild(我们用于在编译器和语言服务中转换节点的函数)上进行了尝试。 相同的重构使生成项目输出的时间减少了高达 3%。

forEachChild 的初步探索受到 Artemis Everfree一篇博客文章的启发。 虽然我们有理由相信加速的根本原因可能与函数大小/复杂性有关,而不是博客文章中描述的问题,但我们很感激能够从经验中学习并尝试相对快速的重构,从而让 TypeScript 更快。

最后,TypeScript 在条件类型的 true 分支中保留类型信息的方式已得到优化。 在像这样的类型中:

ts
interface Zoo<T extends Animal> {
    // ...
}

type MakeZoo<A> = A extends Animal ? Zoo<A> : never;

TypeScript 必须“记住”,在检查 Zoo<A> 是否有效时,A 也必须是 Animal。 这基本上是通过创建一个特殊类型来实现的,该类型过去保存 AAnimal 的交集; 然而,TypeScript 以前是急切地执行此操作,这并不总是必要的。 此外,类型检查器中的一些错误代码阻止了这些特殊类型的简化。 TypeScript 现在会延迟这些类型的交集,直到必要时才进行。 对于大量使用条件类型的代码库,你可能会看到 TypeScript 的显著加速,但在我们的性能测试套件中,我们看到了更温和的 3% 的类型检查时间减少。

你可以在各自的拉取请求中阅读有关这些优化的更多信息:

正确性修复和破坏性更改

lib.d.ts 更新

虽然 TypeScript 努力避免重大破坏,但即使内置库中的微小更改也可能导致问题。 我们预计 DOM 和 lib.d.ts 更新不会导致重大破坏,但可能会有一些小问题。

更好的 Promise.resolve 类型

Promise.resolve 现在使用 Awaited 类型来解包传递给它的类 Promise 类型。 这意味着它更常返回正确的 Promise 类型,但如果期望的是 anyunknown 而不是 Promise,这种改进的类型可能会破坏现有代码。 有关更多信息,请参阅原始更改

JavaScript 输出不再省略导入

当 TypeScript 首次支持 JavaScript 的类型检查和编译时,它意外地支持了一个称为导入省略的特性。 简而言之,如果导入未被用作值,或者编译器可以检测到导入在运行时并不引用某个值,则编译器会在输出期间删除该导入。

这种行为是有问题的,尤其是检测导入是否不引用某个值,因为这意味着 TypeScript 必须信任有时不准确的声明文件。 因此,TypeScript 现在在 JavaScript 文件中保留导入。

js
// 输入:
import { someValue, SomeClass } from "some-module";

/** @type {SomeClass} */
let val = someValue;

// 以前的输出:
import { someValue } from "some-module";

/** @type {SomeClass} */
let val = someValue;

// 当前的输出:
import { someValue, SomeClass } from "some-module";

/** @type {SomeClass} */
let val = someValue;

更多信息可在实现更改中找到。

exports 优先于 typesVersions

以前,在 --moduleResolution node16 下通过 package.json 解析时,TypeScript 错误地将 typesVersions 字段优先于 exports 字段。 如果此更改影响了你的库,你可能需要在 package.jsonexports 字段中添加 types@ 版本选择器。

diff
  {
      "type": "module",
      "main": "./dist/main.js"
      "typesVersions": {
          "<4.8": { ".": ["4.8-types/main.d.ts"] },
          "*": { ".": ["modern-types/main.d.ts"] }
      },
      "exports": {
          ".": {
+             "types@<4.8": "./4.8-types/main.d.ts",
+             "types": "./modern-types/main.d.ts",
              "import": "./dist/main.js"
          }
      }
  }

有关更多信息,请参阅此拉取请求

substitute 被替换为 SubstitutionType 上的 constraint

作为替代类型优化的一部分,SubstitutionType 对象不再包含 substitute 属性(表示有效的替代,通常是基类型和隐式约束的交集)——相反,它们只包含 constraint 属性。

有关更多详细信息,请阅读原始拉取请求