在最后一次赋值后闭包中保留类型收窄
TypeScript 通常可以根据你执行的检查来为变量推断出更具体的类型。这个过程被称为“类型收窄”(narrowing)。
tsfunction uppercaseStrings(x: string | number) {if (typeof x === "string") {// TypeScript knows 'x' is a 'string' here.return x.toUpperCase();}}
一个常见的痛点是,这些收窄后的类型并不总是能在函数闭包中得以保留。
tsfunction getUrls(url: string | URL, names: string[]) {if (typeof url === "string") {url = new URL(url);}return names.map(name => {url.searchParams.set("name", name)// ~~~~~~~~~~~~// error!// Property 'searchParams' does not exist on type 'string | URL'.return url.toString();});}
在之前的情况下,TypeScript 认为假设 url 在回调函数中确实是一个 URL 对象并不“安全”,因为它可能在其他地方被修改过;然而,在这个例子中,该箭头函数总是在对 url 的那次赋值之后创建的,并且这确实是 url 的最后一次赋值。
TypeScript 5.4 利用了这一点,使类型收窄变得更加智能。当参数和 let 变量在非提升(hoisted)的函数中使用时,类型检查器会查找最后一次赋值的点。如果找到了,TypeScript 就可以安全地从包含该函数的外部进行收窄。这意味着上面的例子现在可以直接工作了。
请注意,如果变量在嵌套函数中被赋值,则不会触发收窄分析。这是因为无法确定该函数是否会在稍后被调用。
tsfunction printValueLater(value: string | undefined) {if (value === undefined) {value = "missing!";}setTimeout(() => {// Modifying 'value', even in a way that shouldn't affect// its type, will invalidate type refinements in closures.value = value;}, 500);setTimeout(() => {console.log(value.toUpperCase());// ~~~~~// error! 'value' is possibly 'undefined'.}, 1000);}
这应该会让许多典型的 JavaScript 代码更容易表达。你可以在 GitHub 上阅读有关此更改的更多信息。
NoInfer 工具类型
在调用泛型函数时,TypeScript 能够根据你传入的任何内容推断类型参数。
tsfunction doSomething<T>(arg: T) {// ...}// We can explicitly say that 'T' should be 'string'.doSomething<string>("hello!");// We can also just let the type of 'T' get inferred.doSomething("hello!");
然而,一个挑战是:什么是“最佳”推断类型并不总是显而易见的。这可能导致 TypeScript 拒绝合法的调用、接受有问题的调用,或者在捕获错误时提供较差的错误信息。
例如,想象一个 createStreetLight 函数,它接受一个颜色名称列表,以及一个可选的默认颜色。
tsfunction createStreetLight<C extends string>(colors: C[], defaultColor?: C) {// ...}createStreetLight(["red", "yellow", "green"], "red");
当我们传入一个不在原始 colors 数组中的 defaultColor 时会发生什么?在这个函数中,colors 应该是“真值来源”,描述了可以传给 defaultColor 的内容。
ts// Oops! This is undesirable, but is allowed!createStreetLight(["red", "yellow", "green"], "blue");
在这次调用中,类型推断认为 "blue" 与 "red"、"yellow" 或 "green" 一样合法。因此,TypeScript 没有拒绝该调用,而是将 C 的类型推断为 "red" | "yellow" | "green" | "blue"。你可能会说,推断结果“搞砸了”(blue up in our faces)!
目前人们处理这个问题的一种方法是添加一个由现有类型参数约束的单独类型参数。
tsfunction createStreetLight<C extends string, D extends C>(colors: C[], defaultColor?: D) {}createStreetLight(["red", "yellow", "green"], "blue");// ~~~~~~// error!// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
这虽然可行,但有点笨拙,因为 D 可能不会在 createStreetLight 的其他签名中使用。虽然在这种情况下不算太糟,但类型参数在签名中仅使用一次通常是一种代码异味。
这就是 TypeScript 5.4 引入新的 NoInfer<T> 工具类型的原因。将类型用 NoInfer<...> 包裹起来,向 TypeScript 发出一个信号:不要深入研究并匹配内部类型以寻找类型推断的候选者。
使用 NoInfer,我们可以将 createStreetLight 重写为类似这样:
tsfunction createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {// ...}createStreetLight(["red", "yellow", "green"], "blue");// ~~~~~~// error!// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
将 defaultColor 的类型排除在推断范围之外,意味着 "blue" 永远不会成为推断候选者,类型检查器从而可以拒绝它。
你可以在 实现该功能的 PR 中查看具体更改,以及感谢 Mateusz Burzyński 提供的 最初实现!
Object.groupBy 和 Map.groupBy
TypeScript 5.4 增加了 JavaScript 新的 Object.groupBy 和 Map.groupBy 静态方法的声明。
Object.groupBy 接受一个可迭代对象,以及一个决定每个元素应放置在哪个“组”的函数。该函数需要为每个不同的组创建一个“键”,Object.groupBy 使用该键创建一个对象,其中每个键映射到一个包含原始元素的数组。
因此,下面的 JavaScript 代码
jsconst array = [0, 1, 2, 3, 4, 5];const myObj = Object.groupBy(array, (num, index) => {return num % 2 === 0 ? "even": "odd";});
基本等同于编写如下代码
jsconst myObj = {even: [0, 2, 4],odd: [1, 3, 5],};
Map.groupBy 类似,但产生的是一个 Map 而不是普通对象。如果你需要 Map 的保证、正在处理期望 Map 的 API,或者需要使用任何类型的键进行分组(不仅仅是能用作 JavaScript 属性名称的键),这可能更可取。
jsconst myObj = Map.groupBy(array, (num, index) => {return num % 2 === 0 ? "even" : "odd";});
和之前一样,你可以以等效的方式创建 myObj
jsconst myObj = new Map();myObj.set("even", [0, 2, 4]);myObj.set("odd", [1, 3, 5]);
请注意,在上面的 Object.groupBy 示例中,生成的对象属性全是可选的。
tsinterface EvenOdds {even?: number[];odd?: number[];}const myObj: EvenOdds = Object.groupBy(...);myObj.even;// ~~~~// Error to access this under 'strictNullChecks'.
这是因为无法以通用的方式保证 groupBy 产生了所有可能的键。
还要注意,这些方法只有在将 target 配置为 esnext 或调整 lib 设置时才可用。我们预计它们最终将在稳定的 es2024 目标下可用。
我们要感谢 Kevin Gibbons 为这些 groupBy 方法添加声明。
在 --moduleResolution bundler 和 --module preserve 中支持 require() 调用
TypeScript 有一个名为 bundler 的 moduleResolution 选项,旨在模拟现代打包工具(bundlers)确定导入路径指向哪个文件的方式。该选项的局限性之一是它必须与 --module esnext 配对使用,这使得无法使用 import ... = require(...) 语法。
ts// previously erroredimport myModule = require("module/path");
如果你计划只编写标准的 ECMAScript import,这可能没什么大不了的,但当使用带有条件导出(conditional exports)的包时,情况就不同了。
在 TypeScript 5.4 中,当将 module 设置为名为 preserve 的新选项时,现在可以使用 require()。
结合 --module preserve 和 --moduleResolution bundler,这两者更准确地模拟了打包工具和 Bun 等运行时允许的内容以及它们执行模块查找的方式。事实上,当使用 --module preserve 时,bundler 选项将隐式设置为 --moduleResolution(以及 --esModuleInterop 和 --resolveJsonModule)。
json{"compilerOptions": {"module": "preserve",// ^ also implies:// "moduleResolution": "bundler",// "esModuleInterop": true,// "resolveJsonModule": true,// ...}}
在 --module preserve 下,ECMAScript 的 import 将始终原样输出,而 import ... = require(...) 将被输出为 require() 调用(虽然实际上你甚至可能不会使用 TypeScript 进行输出,因为你很可能会使用打包工具来处理代码)。无论包含文件的文件扩展名如何,此规则都适用。因此,这段代码的输出
tsimport * as foo from "some-package/foo";import bar = require("some-package/bar");
看起来应该像这样
jsimport * as foo from "some-package/foo";var bar = require("some-package/bar");
这也意味着你选择的语法决定了如何匹配条件导出。因此在上面的例子中,如果 some-package 的 package.json 看起来像这样
json{"name": "some-package","version": "0.0.1","exports": {"./foo": {"import": "./esm/foo-from-import.mjs","require": "./cjs/foo-from-require.cjs"},"./bar": {"import": "./esm/bar-from-import.mjs","require": "./cjs/bar-from-require.cjs"}}}
TypeScript 会将这些路径解析为 [...]/some-package/esm/foo-from-import.mjs 和 [...]/some-package/cjs/bar-from-require.cjs。
有关更多信息,你可以在这里阅读关于这些新设置的内容。
已检查的导入属性和断言
现在,导入属性和断言会针对全局的 ImportAttributes 类型进行检查。这意味着运行时现在可以更准确地描述导入属性。
ts// In some global file.interface ImportAttributes {type: "json";}// In some other moduleimport * as ns from "foo" with { type: "not-json" };// ~~~~~~~~~~// error!//// Type '{ type: "not-json"; }' is not assignable to type 'ImportAttributes'.// Types of property 'type' are incompatible.// Type '"not-json"' is not assignable to type '"json"'.
此更改感谢 Oleksandr Tarasiuk 提供。
添加缺失参数的快速修复
TypeScript 现在有一个快速修复功能,可以为参数过多调用的函数添加新参数。


这在需要将一个新参数传递给多个现有函数时很有用,目前这种操作可能比较繁琐。
此快速修复由 Oleksandr Tarasiuk 友情提供。
TypeScript 5.0 中废弃内容的后续更改
TypeScript 5.0 废弃了以下选项和行为:
charsettarget: ES3importsNotUsedAsValuesnoImplicitUseStrictnoStrictGenericCheckskeyofStringsOnlysuppressExcessPropertyErrorssuppressImplicitAnyIndexErrorsoutpreserveValueImports- 项目引用中的
prepend - 隐式的特定于操作系统的
newLine
为了继续使用它们,使用 TypeScript 5.0 及更高版本的开发者必须指定一个名为 ignoreDeprecations 的新选项,并将其值设为 "5.0"。
然而,TypeScript 5.4 将是这些选项能继续正常工作的最后一个版本。到 TypeScript 5.5(预计 2024 年 6 月),这些将成为硬性错误,使用它们的代码将需要进行迁移。
有关更多信息,你可以在 GitHub 上阅读该计划,其中包含关于如何最好地调整代码库的建议。
显著的行为更改
本节重点介绍了一组值得注意的更改,在进行任何升级时都应予以确认和理解。有时它会突出显示弃用、移除和新的限制。它也可能包含功能上有所改进但通过引入新错误而可能影响现有构建的错误修复。
lib.d.ts 变更
为 DOM 生成的类型可能会对代码库的类型检查产生影响。有关更多信息,请参见 TypeScript 5.4 的 DOM 更新。
更准确的条件类型约束
以下代码不再允许在 foo 函数中进行第二个变量声明。
tstype IsArray<T> = T extends any[] ? true : false;function foo<U extends object>(x: IsArray<U>) {let first: true = x; // Errorlet second: false = x; // Error, but previously wasn't}
以前,当 TypeScript 检查 second 的初始化器时,它需要确定 IsArray<U> 是否可分配给单元类型 false。虽然 IsArray<U> 在任何明显的方式下都不兼容,但 TypeScript 也会查看该类型的约束。在 T extends Foo ? TrueBranch : FalseBranch 这样的条件类型中(T 是泛型),类型系统会查看 T 的约束,将其代入 T 本身,并决定是采用真分支还是假分支。
但这种行为是不准确的,因为它过于急切。即使 T 的约束不可分配给 Foo,也不意味着它不会被实例化为符合条件的对象。因此,更正确的行为是在无法证明 T 绝不或总是扩展 Foo 的情况下,为该条件类型的约束产生一个联合类型。
TypeScript 5.4 采用了这种更准确的行为。在实践中,这意味着你可能会发现某些条件类型的实例不再与它们的分支兼容。
更积极地减少类型变量与原始类型之间的交集
TypeScript 现在更积极地减少类型变量与原始类型之间的交集,具体取决于类型变量的约束如何与这些原始类型重叠。
tsdeclare function intersect<T, U>(x: T, y: U): T & U;function foo<T extends "abc" | "def">(x: T, str: string, num: number) {// Was 'T & string', now is just 'T'let a = intersect(x, str);// Was 'T & number', now is just 'never'let b = intersect(x, num)// Was '(T & "abc") | (T & "def")', now is just 'T'let c = Math.random() < 0.5 ?intersect(x, "abc") :intersect(x, "def");}
有关更多信息,请参阅此处的更改。
改进对带有插值的模板字符串的检查
TypeScript 现在能更准确地检查字符串是否可分配给模板字符串类型的占位符槽位。
tsfunction a<T extends {id: string}>() {let x: `-${keyof T & string}`;// Used to error, now doesn't.x = "-id";}
这种行为更理想,但可能会破坏使用条件类型等结构的代码,因为这些规则的变化很容易观察到。
查看此更改以获取更多详细信息。
仅类型导入与本地值冲突时的错误
以前,如果对 Something 的导入仅指代类型,TypeScript 会在 isolatedModules 下允许以下代码。
tsimport { Something } from "./some/path";let Something = 123;
然而,对于单文件编译器来说,假设删除 import 是“安全”的并不合理,即使该代码在运行时注定会失败。在 TypeScript 5.4 中,此代码将触发如下错误:
Import 'Something' conflicts with local value, so must be declared with a type-only import when 'isolatedModules' is enabled.
解决方法应该是进行本地重命名,或者如错误所述,将 type 修饰符添加到导入中。
tsimport type { Something } from "./some/path";// orimport { type Something } from "./some/path";
新的枚举可分配性限制
当两个枚举具有相同的声明名称和枚举成员名称时,它们以前总是被认为是兼容的;然而,当数值已知时,TypeScript 会静默允许它们具有不同的值。
TypeScript 5.4 收紧了这一限制,要求当枚举值已知时,它们必须完全相同。
tsnamespace First {export enum SomeEnum {A = 0,B = 1,}}namespace Second {export enum SomeEnum {A = 0,B = 2,}}function foo(x: First.SomeEnum, y: Second.SomeEnum) {// Both used to be compatible - no longer the case,// TypeScript errors with something like://// Each declaration of 'SomeEnum.B' differs in its value, where '1' was expected but '2' was given.x = y;y = x;}
此外,当其中一个枚举成员没有静态已知值时,也有新的限制。在这些情况下,另一个枚举必须至少是隐式数字类型的(例如,它没有静态解析的初始化器),或者是显式数字类型的(意味着 TypeScript 可以将该值解析为数字)。实际上,这意味着字符串枚举成员仅与具有相同值的其他字符串枚举兼容。
tsnamespace First {export declare enum SomeEnum {A,B,}}namespace Second {export declare enum SomeEnum {A,B = "some known string",}}function foo(x: First.SomeEnum, y: Second.SomeEnum) {// Both used to be compatible - no longer the case,// TypeScript errors with something like://// One value of 'SomeEnum.B' is the string '"some known string"', and the other is assumed to be an unknown numeric value.x = y;y = x;}
有关更多信息,请参阅引入此更改的 PR。
枚举成员的名称限制
TypeScript 不再允许枚举成员使用 Infinity、-Infinity 或 NaN 作为名称。
ts// Errors on all of these://// An enum member cannot have a numeric name.enum E {Infinity = 0,"-Infinity" = 1,NaN = 2,}
在带有 any 剩余元素的元组上实现更好的映射类型保留
以前,将带有 any 的映射类型应用于元组会创建 any 元素类型。这是不理想的,现已修复。
tsPromise.all(["", ...([] as any)]).then((result) => {const head = result[0]; // 5.3: any, 5.4: stringconst tail = result.slice(1); // 5.3 any, 5.4: any[]});
有关更多信息,请参阅修复方案以及关于行为更改的后续讨论和进一步的调整。
输出(Emit)更改
虽然这本身不是破坏性更改,但开发者可能隐式地依赖了 TypeScript 的 JavaScript 或声明文件输出。以下是显著的变化: