改进的交集缩减、联合类型兼容性与类型收窄
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 进行的项目引用构建。例如,TypeScript 现在能够在 --watch 模式下出现空操作更改时避免更新时间戳,这使得重新构建更快,并避免了干扰可能正在监视 TypeScript 输出的其他构建工具。此外,还引入了许多其他优化,使我们能够在 --build、--watch 和 --incremental 之间重用信息。
这些改进有多大?在相当庞大的内部代码库中,我们发现许多简单常见操作的时间减少了约 10%-25%,而在无更改场景下时间减少了约 40%。我们在 TypeScript 代码库上也看到了类似的结果。
您可以在 GitHub 上查看这些更改以及性能结果。
比较对象和数组字面量时的错误
在许多语言中,像 == 这样的运算符会对对象执行所谓的“值”相等性检查。例如,在 Python 中,通过使用 == 检查值是否等于空列表来判断列表是否为空是合法的。
pyif people_at_home == []:print("here's where I lie, broken inside. </3")adopt_animals()
但在 JavaScript 中并非如此,对象(因此也包括数组)之间的 == 和 === 检查的是两个引用是否指向同一个值。我们认为 JavaScript 中类似的代码充其量是 JavaScript 开发者的陷阱,最坏的情况下是生产代码中的错误。这就是为什么 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,导致 TypeScript 在 --watch 模式和编辑器场景中处理某些文件更改时非常吃力。有时症状是出现陈旧或不准确的错误,需要重启 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 现在可以在聚合引用时表现得更聪明。这使得 TypeScript 在其自身代码库中搜索广泛使用的标识符所花费的时间减少了约 20%。
从自动导入中排除特定文件
TypeScript 4.8 引入了一个编辑器首选项,用于从自动导入中排除文件。在 Visual Studio Code 中,可以在设置 UI 的“自动导入排除文件模式(Auto Import File Exclude Patterns)”下添加文件名或 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 以及关于无约束泛型如何工作的工作讨论议题。
装饰器现在放置在 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 模块下,对不存在的值进行命名导入和导出将导致运行时错误。当在 --checkJs 或通过 // @ts-check 注释对 JavaScript 文件进行类型检查时,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 显然接受一个具有类型为 string 的 name 属性和类型为 number 的 age 属性的对象;然而,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;
此更改可以捕获声明中的错误,并有助于改进现有代码。我们要感谢 GitHub 用户 uhyo 提供此检查。您可以阅读有关此更改的信息。