satisfies 运算符
TypeScript 开发者经常面临一个困境:我们既想确保某个表达式符合某种类型,又想保留该表达式的最具体类型以用于类型推断。
例如:
ts// Each property can be a string or an RGB tuple.const palette = {red: [255, 0, 0],green: "#00ff00",bleu: [0, 0, 255]// ^^^^ sacrebleu - we've made a typo!};// We want to be able to use string methods on 'green'...const greenNormalized = palette.green.toUpperCase();
请注意,我们写成了 bleu,但可能本应写成 blue。我们可以尝试通过对 palette 使用类型注解来捕捉 bleu 这个拼写错误,但这样我们会丢失每个属性的具体信息。
tstype 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]// ~~~~ The typo is now correctly detected};// But we now have an undesirable error here - 'palette.green' "could" be of type RGB and// property 'toUpperCase' does not exist on type 'string | RGB'.const greenNormalized = palette.green.toUpperCase();
新的 satisfies 运算符允许我们验证表达式的类型是否符合某种类型,而不会更改该表达式的结果类型。例如,我们可以使用 satisfies 来验证 palette 的所有属性是否与 string | number[] 兼容。
tstype Colors = "red" | "green" | "blue";type RGB = [red: number, green: number, blue: number];const palette = {red: [255, 0, 0],green: "#00ff00",bleu: [0, 0, 255]// ~~~~ The typo is now caught!} satisfies Record<Colors, string | RGB>;// toUpperCase() method is still accessible!const greenNormalized = palette.green.toUpperCase();
satisfies 可用于捕获许多潜在错误。例如,我们可以确保对象拥有某种类型的所有键,且没有多余的键。
tstype Colors = "red" | "green" | "blue";// Ensure that we have exactly the keys from 'Colors'.const favoriteColors = {"red": "yes","green": false,"blue": "kinda","platypus": false// ~~~~~~~~~~ error - "platypus" was never listed in 'Colors'.} satisfies Record<Colors, unknown>;// All the information about the 'red', 'green', and 'blue' properties are retained.const g: boolean = favoriteColors.green;
也许我们不在意属性名是否完全匹配,但我们在意每个属性的类型。在这种情况下,我们也可以确保对象的所有属性值都符合某种类型。
tstype RGB = [red: number, green: number, blue: number];const palette = {red: [255, 0, 0],green: "#00ff00",blue: [0, 0]// ~~~~~~ error!} satisfies Record<string, string | RGB>;// Information about each property is still maintained.const redComponent = palette.red.at(0);const greenNormalized = palette.green.toUpperCase();
更多示例,请参阅提议此功能的 issue 和 实现该功能的 pull request。我们衷心感谢 Oleksandr Tarasiuk,他与我们一起实现了这一功能并进行了迭代。
使用 in 运算符进行未列出属性的类型收窄
作为开发者,我们经常需要处理在运行时并不完全确定的值。事实上,无论是从服务器接收响应还是读取配置文件,我们经常不知道属性是否存在。JavaScript 的 in 运算符可以检查某个属性是否存在于对象中。
以前,TypeScript 允许我们收窄(narrow)掉任何未明确列出该属性的类型。
tsinterface 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' now has the type HSV}// ...}
在这里,类型 RGB 没有列出 hue,因此被收窄剔除,只剩下类型 HSV。
但是,如果没有任何类型列出某个给定的属性呢?在这种情况下,语言并没有提供太多帮助。让我们看下面这个 JavaScript 示例。
jsfunction tryGetPackageName(context) {const packageJSON = context.packageJSON;// Check to see if we have an object.if (packageJSON && typeof packageJSON === "object") {// Check to see if it has a string name property.if ("name" in packageJSON && typeof packageJSON.name === "string") {return packageJSON.name;}}return undefined;}
将其改写为标准的 TypeScript 只需为 context 定义并使用一个类型;然而,为 packageJSON 属性选择一个安全的类型(如 unknown)会在旧版本的 TypeScript 中引发问题。
tsinterface Context {packageJSON: unknown;}function tryGetPackageName(context: Context) {const packageJSON = context.packageJSON;// Check to see if we have an object.if (packageJSON && typeof packageJSON === "object") {// Check to see if it has a string name property.if ("name" in packageJSON && typeof packageJSON.name === "string") {// ~~~~// error! Property 'name' does not exist on type 'object.return packageJSON.name;// ~~~~// error! Property 'name' does not exist on type 'object.}}return undefined;}
这是因为虽然 packageJSON 的类型从 unknown 收窄到了 object,但 in 运算符严格地收窄至那些实际定义了所检查属性的类型。结果,packageJSON 的类型依然保持为 object。
TypeScript 4.9 使 in 运算符在收窄那些根本没有列出该属性的类型时变得更加强大。语言不再保持其原样,而是将它们的类型与 Record<"property-key-being-checked", unknown> 进行交叉(intersect)。
所以,在我们的示例中,packageJSON 的类型将从 unknown 收窄为 object,进而收窄为 object & Record<"name", unknown>。这使我们可以直接访问 packageJSON.name 并对其进行独立的收窄。
tsinterface Context {packageJSON: unknown;}function tryGetPackageName(context: Context): string | undefined {const packageJSON = context.packageJSON;// Check to see if we have an object.if (packageJSON && typeof packageJSON === "object") {// Check to see if it has a string name property.if ("name" in packageJSON && typeof packageJSON.name === "string") {// Just works!return packageJSON.name;}}return undefined;}
TypeScript 4.9 还收紧了对 in 使用方式的一些检查,确保左侧可赋值给 string | number | symbol 类型,右侧可赋值给 object。这有助于检查我们是否使用了有效的属性键,而不是意外地检查了原始类型。
欲了解更多信息,请阅读实现该功能的 pull request。
类中的自动访问器 (Auto-Accessors)
TypeScript 4.9 支持 ECMAScript 即将推出的一项特性,即自动访问器(auto-accessors)。自动访问器的声明方式与类中的属性相同,只是使用了 accessor 关键字。
tsclass Person {accessor name: string;constructor(name: string) {this.name = name;}}
在底层,这些自动访问器会“去糖”为带有不可访问私有属性的 get 和 set 访问器。
tsclass Person {#__name: string;get name() {return this.#__name;}set name(value: string) {this.#__name = value;}constructor(name: string) {this.name = name;}}
你可以在原始 PR 中阅读更多关于自动访问器 pull request 的信息。
针对 NaN 的相等性检查
JavaScript 开发者的一个主要坑点是使用内置的相等运算符来检查 NaN 值。
作为背景,NaN 是一个特殊的数值,代表“非数字”(Not a Number)。没有任何东西等于 NaN —— 即使是 NaN 本身!
jsconsole.log(NaN == 0) // falseconsole.log(NaN === 0) // falseconsole.log(NaN == NaN) // falseconsole.log(NaN === NaN) // false
但至少对称地,任何东西总是与 NaN 不相等。
jsconsole.log(NaN != 0) // trueconsole.log(NaN !== 0) // trueconsole.log(NaN != NaN) // trueconsole.log(NaN !== NaN) // true
这在技术上并非 JavaScript 特有的问题,因为任何包含 IEEE-754 浮点数的语言都具有相同的行为;但 JavaScript 的主要数值类型是浮点数,且在 JavaScript 中解析数字经常会导致 NaN。因此,检查 NaN 变得相当普遍,而正确的方法是使用 Number.isNaN —— 但正如我们提到的,许多人会不小心使用 someValue === NaN 来检查。
现在,TypeScript 对与 NaN 的直接比较报错,并建议改用 Number.isNaN 的某种变体。
tsfunction validate(someValue: number) {return someValue !== NaN;// ~~~~~~~~~~~~~~~~~// error: This condition will always return 'true'.// Did you mean '!Number.isNaN(someValue)'?}
我们认为这一改变将严格有助于捕捉新手错误,类似于 TypeScript 目前对对象和数组字面量比较发出的错误警告。
我们衷心感谢 Oleksandr Tarasiuk,他贡献了这一检查功能。
文件监控现已使用文件系统事件
在早期版本中,TypeScript 在监控单个文件时严重依赖轮询(polling)。使用轮询策略意味着定期检查文件的状态以查看更新。在 Node.js 上,fs.watchFile 是获取轮询文件监控器的内置方式。虽然轮询在跨平台和文件系统方面往往更可预测,但这意味着你的 CPU 必须定期被中断以检查文件更新,即使没有任何变化。对于几十个文件,这可能不明显;但对于大型项目——或 node_modules 中有大量文件——这可能会成为资源消耗大户。
总的来说,更好的方法是使用文件系统事件。无需轮询,我们可以声明我们要监控特定文件的更新,并提供一个在这些文件确实发生变化时的回调。大多数现代平台都提供如 CreateIoCompletionPort、kqueue、epoll 和 inotify 等设施和 API。Node.js 主要通过提供 fs.watch 来抽象这些 API。文件系统事件通常工作得很好,但在使用 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 仅支持两个管理导入的编辑器命令。对于我们的示例,请看以下代码。
tsimport { Zebra, Moose, HoneyBadger } from "./zoo";import { foo, bar } from "./helper";let x: Moose | HoneyBadger = foo();
第一个称为“组织导入”(Organize Imports),它会删除未使用的导入,然后对剩余的导入进行排序。它会将该文件重写为如下所示。
tsimport { foo } from "./helper";import { HoneyBadger, Moose } from "./zoo";let x: Moose | HoneyBadger = foo();
在 TypeScript 4.3 中,我们引入了一个名为“排序导入”(Sort Imports)的命令,它仅对文件中的导入进行排序,而不删除它们——并会将文件重写为如下所示。
tsimport { bar, foo } from "./helper";import { HoneyBadger, Moose, Zebra } from "./zoo";let x: Moose | HoneyBadger = foo();
“排序导入”的一个注意事项是,在 Visual Studio Code 中,此功能仅作为“保存时”命令提供,而不是作为可手动触发的命令。
TypeScript 4.9 添加了另一半功能,现在提供了“删除未使用的导入”(Remove Unused Imports)。TypeScript 现在将删除未使用的导入名称和语句,但在其他方面保持相对顺序不变。
tsimport { Moose, HoneyBadger } from "./zoo";import { foo } from "./helper";let x: Moose | HoneyBadger = foo();
此功能适用于所有希望使用这两个命令的编辑器;但值得注意的是,Visual Studio Code(1.73 及更高版本)将内置支持并通过其命令面板(Command Palette)显示这些命令。喜欢使用更细粒度的“删除未使用的导入”或“排序导入”命令的用户,如果需要,可以将“组织导入”的快捷键组合重新分配给它们。
你可以在此处查看该功能的细节。
在 return 关键字上跳转到定义
在编辑器中,当在 return 关键字上执行“跳转到定义”时,TypeScript 现在会让你跳转到相应函数的顶部。这有助于快速了解 return 属于哪个函数。
我们预计 TypeScript 会将此功能扩展到更多关键字,例如 await 和 yield,或者switch、case 和 default。
此功能得以实现要感谢 Oleksandr Tarasiuk。
性能改进
TypeScript 包含一些虽小但显著的性能改进。
首先,TypeScript 的 forEachChild 函数已被重写,使用函数表查找来代替跨所有语法节点的 switch 语句。forEachChild 是编译器中遍历语法节点的功臣,在编译器的绑定阶段以及语言服务的多个部分被大量使用。forEachChild 的重构使我们的绑定阶段和语言服务操作的时间减少了多达 20%。
在我们发现 forEachChild 的性能提升后,我们将其尝试应用于 visitEachChild,这是我们用于在编译器和语言服务中转换节点的函数。同样的重构使生成项目输出的时间减少了多达 3%。
forEachChild 的初步探索受到 Artemis Everfree 一篇博文的启发。虽然我们有理由相信性能提升的根本原因可能与函数大小/复杂性有关,而不是博文中描述的问题,但我们很感谢能够从中学习并尝试了一次相对快速的重构,从而使 TypeScript 运行得更快。
最后,TypeScript 在条件类型(conditional type)的 true 分支中保存类型信息的方式得到了优化。在如下类型中:
tsinterface Zoo<T extends Animal> {// ...}type MakeZoo<A> = A extends Animal ? Zoo<A> : never;
在检查 Zoo<A> 是否有效时,TypeScript 必须“记住” A 也必须是 Animal。这基本上是通过创建一个特殊的类型来完成的,该类型过去用于保存 A 与 Animal 的交集;然而,TypeScript 之前是急切地执行此操作的,这并不总是必要的。此外,我们类型检查器中的一些错误代码阻止了这些特殊类型被简化。现在,TypeScript 会延迟这些类型的交集运算,直到必要时才进行。对于大量使用条件类型的代码库,你可能会见证 TypeScript 的显著速度提升,但在我们的性能测试套件中,我们观察到类型检查时间有了较温和的 3% 减少。
你可以在各自的 pull request 中阅读有关这些优化的更多信息。
正确性修复与破坏性更改
lib.d.ts 更新
虽然 TypeScript 努力避免重大破坏,但即使是内置库中的小改动也可能导致问题。我们预计 DOM 和 lib.d.ts 更新不会导致重大破坏,但可能会有一些微小的破坏。
为 Promise.resolve 提供更好的类型
Promise.resolve 现在使用 Awaited 类型来解包传递给它的 Promise 类类型。这意味着它更经常返回正确的 Promise 类型,但如果现有代码期望 any 或 unknown 而不是 Promise,这种改进后的类型可能会破坏现有代码。更多信息,请参阅最初的更改。
JavaScript 发出(emit)不再忽略导入
当 TypeScript 最初支持 JavaScript 的类型检查和编译时,它无意中支持了一个称为“导入忽略”(import elision)的特性。简而言之,如果一个导入没有作为值使用,或者编译器可以检测到该导入在运行时不引用值,那么编译器就会在输出中删除该导入。
这种行为是有问题的,特别是对于“检测导入是否不引用值”这一机制,因为这意味着 TypeScript 必须信任有时不准确的声明文件。因此,TypeScript 现在在 JavaScript 文件中保留导入。
js// Input:import { someValue, SomeClass } from "some-module";/** @type {SomeClass} */let val = someValue;// Previous Output:import { someValue } from "some-module";/** @type {SomeClass} */let val = someValue;// Current Output:import { someValue, SomeClass } from "some-module";/** @type {SomeClass} */let val = someValue;
更多信息可在实现该更改的 PR 中获取。
exports 的优先级高于 typesVersions
以前,当在 --moduleResolution node16 下通过 package.json 解析时,TypeScript 错误地将 typesVersions 字段的优先级置于 exports 字段之上。如果此更改影响了你的库,你可能需要在 package.json 的 exports 字段中添加 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"}}}
更多信息,请参阅此 pull request。
SubstitutionType 上的 substitute 被 constraint 取代
作为对替换类型(substitution types)优化的一部分,SubstitutionType 对象不再包含表示有效替换的 substitute 属性(通常是基类型和隐式约束的交集),而是只包含 constraint 属性。
更多详情,请在原始 pull request 中阅读。