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 允许我们把任何没有显式列出某个属性的类型排除在收窄结果之外。
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> 做交叉。
因此在本例中,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。这有助于检查我们是否使用了有效的属性键,而不是误对原始类型做检查。
类中的自动访问器
TypeScript 4.9 支持 ECMAScript 中即将加入的一项特性,称为自动访问器。自动访问器的声明方式与类上的属性相同,只是要使用 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;}}
对 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 在监视单个文件时非常依赖轮询。采用轮询策略意味着要定期检查文件的状态以获取更新。在 Node.js 上,fs.watchFile 是内置的获取轮询式文件监视器的方式。虽然轮询在各个平台和文件系统上往往更具可预测性,但它意味着 CPU 必须定期被中断去检查文件是否有更新,即使什么都没变。对于几十个文件来说,这可能不易察觉;但在文件很多的大型项目中——或者 node_modules 里有大量文件时——这就可能变成资源消耗大户。
一般来说,更好的做法是使用文件系统事件。与其轮询,我们可以声明自己对特定文件的更新感兴趣,并为这些文件确实发生变化时提供回调。如今使用的大多数现代平台都提供了诸如 CreateIoCompletionPort、kqueue、epoll 和 inotify 这样的设施和 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 只支持两个管理导入的编辑器命令。以我们的示例为例,下面这段代码:
tsimport { Zebra, Moose, HoneyBadger } from "./zoo";import { foo, bar } from "./helper";let x: Moose | HoneyBadger = foo();
第一个命令叫“组织导入”,它会删除未使用的导入,然后对剩余的导入排序。它会把该文件重写成这样:
tsimport { foo } from "./helper";import { HoneyBadger, Moose } from "./zoo";let x: Moose | HoneyBadger = foo();
在 TypeScript 4.3 中,我们引入了一个名为“排序导入”的命令,它只对文件中的导入排序,而不删除它们——并把文件重写成这样。
tsimport { bar, foo } from "./helper";import { HoneyBadger, Moose, Zebra } from "./zoo";let x: Moose | HoneyBadger = foo();
“排序导入”的局限在于,在 Visual Studio Code 中,此功能仅作为保存时命令提供——而不是可手动触发的命令。
TypeScript 4.9 补上了另一半,现在提供“删除未使用的导入”。TypeScript 会删除未使用的导入名称和语句,但除此之外保持相对顺序不变。
tsimport { Moose, HoneyBadger } from "./zoo";import { foo } from "./helper";let x: Moose | HoneyBadger = foo();
此功能可供所有希望使用任一命令的编辑器使用;尤其是 Visual Studio Code(1.73 及更高版本)将内置支持,并且会通过其命令面板呈现这些命令。喜欢使用更细粒度的“删除未使用的导入”或“排序导入”命令的用户,如果愿意,应该可以把“组织导入”的快捷键重新分配给它们。
你可以在此查看该功能的具体细节。
对 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 在条件类型 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 输出不再省略导入
当 TypeScript 最初支持对 JavaScript 进行类型检查和编译时,它意外地支持了一项称为导入省略的功能。简而言之,如果某个导入没有被用作值,或者编译器能够检测到该导入在运行时并不指代某个值,编译器就会在输出时丢弃该导入。
这种行为值得商榷,尤其是检测导入是否指代某个值这一点,因为这意味着 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;
更多信息请参阅实现此更改的相关改动。
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
作为替换类型优化的一部分,SubstitutionType 对象不再包含表示实际替换(通常是基类型与隐式约束的交叉类型)的 substitute 属性——它们现在只包含 constraint 属性。
更多详情请参阅最初的 pull request。