改进交叉类型归约、联合类型兼容性与类型收窄
TypeScript 4.8 在 --strictNullChecks 下带来了一系列正确性和一致性方面的改进。这些改动会影响交叉类型和联合类型的工作方式,并被用于 TypeScript 收窄类型的过程。
例如,unknown 在精神上接近于联合类型 {} | null | undefined,因为它接受 null、undefined 以及任何其他类型。TypeScript 现在认识到了这一点,并允许将 unknown 赋值给 {} | null | undefined。
tsfunction f(x: unknown, y: {} | null | undefined) {x = y; // always workedy = x; // used to error, now works}
另一项改动是,{} 与任何其他对象类型求交后,会直接简化为该对象类型。这意味着我们能够改写 NonNullable,让它只需与 {} 求交,因为 {} & null 和 {} & undefined 会被直接丢弃。
diff- type NonNullable<T> = T extends null | undefined ? never : T;+ type NonNullable<T> = T & {};
这是一项改进,因为这样的交叉类型可以被归约,也可以作为赋值目标,而条件类型目前还不行。因此 NonNullable<NonNullable<T>> 现在至少能简化为 NonNullable<T>,而在以前并不能做到。
tsfunction foo<T>(x: NonNullable<T>, y: NonNullable<NonNullable<T>>) {x = y; // always workedy = x; // used to error, now works}
这些改动还让我们能在控制流分析和类型收窄方面带来合理的改进。例如,在真值分支中,unknown 现在会像 {} | null | undefined 一样被收窄。
tsfunction narrowUnknownishUnion(x: {} | null | undefined) {if (x) {x; // {}}else {x; // {} | null | undefined}}function narrowUnknown(x: unknown) {if (x) {x; // used to be 'unknown', now '{}'}else {x; // unknown}}
泛型值也会以类似方式被收窄。在检查某个值不是 null 或 undefined 时,TypeScript 现在只需将其与 {} 求交——这同样等同于说它是 NonNullable。把这里的许多改动放在一起,我们现在可以定义下面这个函数,而无需任何类型断言。
tsfunction throwIfNullable<T>(value: T): NonNullable<T> {if (value === undefined || value === null) {throw Error("Nullable value!");}// Used to fail because 'T' was not assignable to 'NonNullable<T>'.// Now narrows to 'T & {}' and succeeds because that's just 'NonNullable<T>'.return value;}
value 现在会被收窄为 T & {},与 NonNullable<T> 等价——因此函数体无需任何 TypeScript 专有语法即可正常工作。
单独来看,这些改动或许显得很小——但它们修复了多年来报告的许多许多细碎痛点。
关于这些改进的更多细节,你可以在此处阅读更多内容。
改进模板字符串类型中 infer 类型的推断
TypeScript 最近引入了一种方式,可以为条件类型中的 infer 类型变量添加 extends 约束。
ts// Grabs the first element of a tuple if it's assignable to 'number',// and returns 'never' if it can't find one.type TryGetNumberIfFirst<T> =T extends [infer U extends number, ...unknown[]] ? U : never;
如果这些 infer 类型出现在模板字符串类型中,并且被约束为原始类型,TypeScript 现在会尝试解析出字面量类型。
ts// SomeNum used to be 'number'; now it's '100'.type SomeNum = "100" extends `${infer U extends number}` ? U : never;// SomeBigInt used to be 'bigint'; now it's '100n'.type SomeBigInt = "100" extends `${infer U extends bigint}` ? U : never;// SomeBool used to be 'boolean'; now it's 'true'.type SomeBool = "true" extends `${infer U extends boolean}` ? U : never;
这现在能更好地表达库在运行时的行为,并给出更精确的类型。
需要注意的是,当 TypeScript 解析这些字面量类型时,它会贪婪地尽可能解析出看起来符合相应原始类型的部分;不过随后它会检查该原始类型写回后的结果是否与字符串内容一致。换句话说,TypeScript 会检查从字符串到原始类型再转回来的过程是否匹配。如果它发现字符串无法“往返转换”,就会回退到基本的原始类型。
ts// JustNumber is `number` here because TypeScript parses out `"1.0"`, but `String(Number("1.0"))` is `"1"` and doesn't match.type JustNumber = "1.0" extends `${infer T extends number}` ? T : never;
你可以在此处进一步了解这项功能。
--build、--watch 与 --incremental 的性能改进
TypeScript 4.8 引入了多项优化,应当能加快与 --watch 和 --incremental 相关的场景,以及使用 --build 进行项目引用构建的场景。例如,在 --watch 模式下发生无实际变化的改动时,TypeScript 现在能够避免把时间花在更新时间戳上,这使重新构建更快,也避免干扰其他可能在监视 TypeScript 输出的构建工具。此外还引入了许多其他优化,让我们能够在 --build、--watch 和 --incremental 之间复用信息。
这些改进有多大呢?在一个相当大的内部代码库上,我们看到许多简单的常见操作耗时减少了约 10% 到 25%,在无变化场景下耗时减少约 40%。我们在 TypeScript 代码库上也看到了类似的结果。
比较对象字面量与数组字面量时报错
在许多语言中,像 == 这样的运算符会对对象执行所谓的“值”相等比较。例如,在 Python 中,可以通过 == 检查某个值是否等于空列表,来判断列表是否为空,这是合法的。
pyif people_at_home == []:print("here's where I lie, broken inside. </3")adopt_animals()
在 JavaScript 中并非如此:对象(因而也包括数组)之间的 == 和 === 检查的是两个引用是否指向同一个值。我们认为,JavaScript 中的类似代码往好里说是给 JavaScript 开发者早期埋下的陷阱,往坏里说就是生产代码中的 bug。因此 TypeScript 现在禁止下面这样的代码。
tsif (peopleAtHome === []) {// ~~~~~~~~~~~~~~~~~~~// This condition will always return 'false' since JavaScript compares objects by reference, not value.console.log("here's where I lie, broken inside. </3")adoptAnimals();}
我们要感谢 Jack Works 贡献了这项检查。你可以在此处查看相关改动。
改进对绑定模式的类型推断
在某些情况下,TypeScript 会从绑定模式中获取类型,以便做出更好的推断。
tsdeclare function chooseRandomly<T>(x: T, y: T): T;let [a, b, c] = chooseRandomly([42, true, "hi!"], [0, false, "bye!"]);// ^ ^ ^// | | |// | | string// | |// | boolean// |// number
当 chooseRandomly 需要为 T 推断出类型时,它主要会查看 [42, true, "hi!"] 和 [0, false, "bye!"];但 TypeScript 需要判断这两种类型应该是 Array<number | boolean | string> 还是元组类型 [number, boolean, string]。为此,它会查找已有的候选类型作为提示,看看是否存在元组类型。当 TypeScript 看到绑定模式 [a, b, c] 时,会创建类型 [any, any, any],该类型会作为 T 的低优先级候选被采用,同时也被用作 [42, true, "hi!"] 和 [0, false, "bye!"] 的类型提示。
你可以看到这对 chooseRandomly 很有用,但在其他情况下就不够了。例如,看看下面的代码
tsdeclare function f<T>(x?: T): T;let [x, y, z] = f();
绑定模式 [x, y, z] 暗示 f 应该产生一个 [any, any, any] 元组;但 f 实在不应该根据绑定模式改变其类型实参。它无法根据自己被赋值到的位置凭空造出一个新的类数组值,因此绑定模式类型对推断出的类型影响过大。更重要的是,由于绑定模式类型充满 any,最终 x、y 和 z 都被推断为 any。
在 TypeScript 4.8 中,这些绑定模式不再被用作类型实参的候选。相反,它们只会在参数需要更具体的类型时被参考,就像我们在 chooseRandomly 示例中那样。如果需要恢复旧行为,你随时可以提供显式的类型实参。
如果你想进一步了解,可以在 GitHub 上查看这项改动。
文件监视修复(尤其是跨 git checkout 场景)
长期以来一直存在一个 bug:在 --watch 模式和编辑器场景下,TypeScript 处理某些文件变化时非常吃力。有时症状是出现过时或不准确的错误,需要重启 tsc 或 VS Code。这些情况经常发生在 Unix 系统上,你可能在用 vim 保存文件或在 git 中切换分支后见过。
这源于对 Node.js 如何处理跨文件系统的重命名事件的假设。Linux 和 macOS 使用的文件系统基于 inode,而 Node.js 会把文件监视器附加到 inode 而不是文件路径。因此,当 Node.js 返回一个监视器对象时,它监视的可能是一个路径,也可能是一个 inode,取决于平台和文件系统。
为了更高效一些,如果 TypeScript 检测到某个路径在磁盘上仍然存在,它会尝试复用同一个监视器对象。问题就出在这里:即使该路径上仍有文件存在,也可能已经创建了一个不同的文件,而那个文件会有不同的 inode。于是 TypeScript 会复用监视器对象,而不是在原始位置安装新的监视器,从而监视可能完全不相关的文件的变化。因此,TypeScript 4.8 现在会在基于 inode 的系统上处理这些情况,正确地安装新的监视器并修复了该问题。
我们要感谢 Marc Celani 和他在 Airtable 的团队,他们投入了大量时间调查遇到的问题并指出了根本原因。你可以查看此处关于文件监视的具体修复。
查找所有引用的性能改进
在编辑器中运行查找所有引用时,TypeScript 现在在聚合引用时能更聪明一些。这使其在自身代码库中搜索一个广泛使用的标识符所花的时间减少了约 20%。
从自动导入中排除特定文件
TypeScript 4.8 引入了用于从自动导入中排除文件的编辑器首选项。在 Visual Studio Code 中,可以在设置界面的“自动导入文件排除模式”下添加文件名或 glob,也可以在 .vscode/settings.json 文件中添加:
jsonc{// Note that `javascript.preferences.autoImportFileExcludePatterns` can be specified for JavaScript too."typescript.preferences.autoImportFileExcludePatterns": ["**/node_modules/@types/node"]}
当你无法避免在编译中包含某些模块或库,但很少想从它们导入时,这会很有用。这些模块可能有很多导出,会污染自动导入列表并使其更难浏览,此选项在这些情况下会有所帮助。
你可以在此查看关于实现的更多细节。
正确性修复与破坏性变更
由于类型系统变更的性质,很少有改动不会影响某些代码;不过,有一些改动更可能需要调整现有代码。
lib.d.ts 更新
虽然 TypeScript 力求避免重大破坏,但内置库中的微小改动也可能引发问题。我们不认为 DOM 和 lib.d.ts 的更新会导致重大破坏,但一个值得注意的变化是,Error 上的 cause 属性现在的类型是 unknown 而不是 Error。
无约束泛型不再可赋值给 {}
在 TypeScript 4.8 中,对于启用了 strictNullChecks 的项目,当无约束的类型参数被用在 null 或 undefined 不是合法值的位置时,TypeScript 现在会正确地报错。这包括任何期望 {}、object 或所有属性都可选的对象类型的类型。
下面是一个简单的例子。
ts// Accepts any non-null non-undefined valuefunction bar(value: {}) {Object.keys(value); // This call throws on null/undefined at runtime.}// Unconstrained type parameter T...function foo<T>(x: T) {bar(x); // Used to be allowed, now is an error in 4.8.// ~// error: Argument of type 'T' is not assignable to parameter of type '{}'.}foo(undefined);
如上所示,这样的代码存在潜在 bug——null 和 undefined 值可以通过这些无约束的类型参数间接传入本不应该观察到这些值的代码。
这种行为在类型位置也会体现。一个例子是:
tsinterface Foo<T> {x: Bar<T>;}interface Bar<T extends {}> { }
不想处理 null 和 undefined 的现有代码,可以通过传播适当的约束来修复。
diff- function foo<T>(x: T) {+ function foo<T extends {}>(x: T) {
另一种变通方法是在运行时检查 null 和 undefined。
difffunction foo<T>(x: T) {+ if (x !== null && x !== undefined) {bar(x);+ }}
如果你确实知道由于某种原因,你的泛型值不可能是 null 或 undefined,那么直接使用非空断言即可。
difffunction foo<T>(x: T) {- bar(x);+ bar(x!);}
在类型层面,你通常需要传播约束,或者将你的类型与 {} 交叉。
更多信息,你可以查看引入此更改的 PR,以及关于无约束泛型现状的具体讨论 issue。
装饰器被放在 TypeScript 语法树的 modifiers 上
TC39 中装饰器的当前方向意味着 TypeScript 必须处理装饰器放置位置方面的破坏性变化。以前,TypeScript 假定装饰器总是放在所有关键字/修饰符之前。例如
ts@decoratorexport class Foo {// ...}
目前提议的装饰器不支持这种语法。相反,export 关键字必须位于装饰器之前。
tsexport @decorator class Foo {// ...}
遗憾的是,TypeScript 的语法树是具体的而非抽象的,我们的架构要求语法树节点字段之间有完全的顺序。为了同时支持旧式装饰器和提议中的装饰器,TypeScript 必须优雅地解析并交错排列修饰符和装饰器。
为此,它暴露了一个新的类型别名 ModifierLike,它是 Modifier 或 Decorator。
tsexport type ModifierLike = Modifier | Decorator;
装饰器现在被放在与 modifiers 相同的字段中,该字段在设置时是一个 NodeArray<ModifierLike>,而整个字段已被弃用。
diff- readonly modifiers?: NodeArray<Modifier> | undefined;+ /**+ * @deprecated ...+ * Use `ts.canHaveModifiers()` to test whether a `Node` can have modifiers.+ * Use `ts.getModifiers()` to get the modifiers of a `Node`.+ * ...+ */+ readonly modifiers?: NodeArray<ModifierLike> | undefined;
所有现有的 decorators 属性都已标记为弃用,读取时始终为 undefined。其类型也已改为 undefined,以便现有工具知道要正确处理它们。
diff- readonly decorators?: NodeArray<Decorator> | undefined;+ /**+ * @deprecated ...+ * Use `ts.canHaveDecorators()` to test whether a `Node` can have decorators.+ * Use `ts.getDecorators()` to get the decorators of a `Node`.+ * ...+ */+ readonly decorators?: undefined;
为避免新的弃用警告和其他问题,TypeScript 现在暴露了四个新函数,用来代替 decorators 和 modifiers 属性。其中有用于测试节点是否具有所支持修饰符和装饰器的各个谓词,以及用于获取它们的相应访问器函数。
tsfunction canHaveModifiers(node: Node): node is HasModifiers;function getModifiers(node: HasModifiers): readonly Modifier[] | undefined;function canHaveDecorators(node: Node): node is HasDecorators;function getDecorators(node: HasDecorators): readonly Decorator[] | undefined;
作为如何从节点访问修饰符的示例,你可以这样写
tsconst modifiers = canHaveModifiers(myNode) ? getModifiers(myNode) : undefined;
需要注意,每次调用 getModifiers 和 getDecorators 都可能分配一个新数组。
更多信息,请参阅以下相关更改:
无法在 JavaScript 文件中导入/导出类型
此前,TypeScript 允许 JavaScript 文件在 import 和 export 语句中导入和导出只用类型声明、没有对应值的实体。这种行为是不正确的,因为在 ECMAScript 模块下,对不存在的值进行具名导入和导出会导致运行时错误。当 JavaScript 文件在 --checkJs 下或通过 // @ts-check 注释进行类型检查时,TypeScript 现在会报错。
ts// @ts-check// Will fail at runtime because 'SomeType' is not a value.import { someValue, SomeType } from "some-module";/*** @type {SomeType}*/export const myValue = someValue;/*** @typedef {string | number} MyType*/// Will fail at runtime because 'MyType' is not a value.export { MyType as MyExportedType };
要引用另一个模块中的类型,你可以改为直接限定导入。
diff- import { someValue, SomeType } from "some-module";+ import { someValue } from "some-module";/**- * @type {SomeType}+ * @type {import("some-module").SomeType}*/export const myValue = someValue;
要导出类型,你只需在 JSDoc 中使用 /** @typedef */ 注释即可。@typedef 注释本来就会自动从所在模块导出类型。
diff/*** @typedef {string | number} MyType*/+ /**+ * @typedef {MyType} MyExportedType+ */- export { MyType as MyExportedType };
你可以在此进一步了解这项变更。
绑定模式不会直接提供推断候选
如上所述,绑定模式不再改变函数调用中推断结果的类型。你可以在此了解更多关于最初变更的内容。
绑定模式中未使用的重命名现在是类型签名中的错误
TypeScript 的类型注解语法常常看起来可以在解构值时使用。例如,看看下面这个函数。
tsdeclare function makePerson({ name: string, age: number }): Person;
你读到这个签名时,可能会认为 makePerson 显然接受一个对象,其中 name 属性的类型是 string,age 属性的类型是 number;然而,这里实际上是 JavaScript 的解构语法在起作用。makePerson 确实表示它接受一个带有 name 和 age 属性的对象,但它并没有为这两个属性指定类型,而只是表示把 name 和 age 分别重命名为 string 和 number。
在纯粹的类型构造中,这样写代码毫无用处,而且通常是错误的,因为开发者一般认为自己在写类型注解。
TypeScript 4.8 会将这类写法视为错误,除非它们在签名后面被引用。上面那个签名的正确写法如下:
tsdeclare function makePerson(options: { name: string, age: number }): Person;// ordeclare function makePerson({ name, age }: { name: string, age: number }): Person;
这项变更可以捕获声明中的 bug,对改进现有代码很有帮助。我们要感谢 GitHub 用户 uhyo 提供了这项检查。你可以在此详细了解这项变更。