TypeScript 5.4
在最后赋值后的闭包中保留收窄
TypeScript 通常可以根据你执行的检查为变量找出更具体的类型。 这个过程称为收窄。
function uppercaseStrings(x: string | number) {
if (typeof x === "string") {
// TypeScript 知道这里 'x' 是 'string'。
return x.toUpperCase();
}
}一个常见的痛点是,这些收窄后的类型并不总是在函数闭包中保留。
function getUrls(url: string | URL, names: string[]) {
if (typeof url === "string") {
url = new URL(url);
}
return names.map(name => {
url.searchParams.set("name", name)
// ~~~~~~~~~~~~
// 错误!
// 类型 'string | URL' 上不存在属性 'searchParams'。
return url.toString();
});
}这里,TypeScript 认为在我们的回调函数中假设 url 实际上是一个 URL 对象是不“安全”的,因为它在其他地方被改变了; 然而,在这个例子中,该箭头函数总是在对 url 赋值之后创建,并且这也是对 url 的最后赋值。
TypeScript 5.4 利用这一点使收窄更智能一些。 当参数和 let 变量在非提升函数中使用时,类型检查器将寻找最后一个赋值点。 如果找到,TypeScript 可以安全地从包含函数外部进行收窄。 这意味着上面的例子现在可以工作了。
请注意,如果变量在嵌套函数中的任何地方被赋值,收窄分析不会生效。 这是因为无法确定该函数是否会在稍后被调用。
function printValueLater(value: string | undefined) {
if (value === undefined) {
value = "missing!";
}
setTimeout(() => {
// 修改 'value',即使是以不影响其类型的方式,
// 也会使闭包中的类型细化失效。
value = value;
}, 500);
setTimeout(() => {
console.log(value.toUpperCase());
// ~~~~~
// 错误!'value' 可能为 'undefined'。
}, 1000);
}这应该会让许多典型的 JavaScript 代码更容易表达。 你可以在 GitHub 上阅读更多关于此更改的信息。
NoInfer 实用类型
当调用泛型函数时,TypeScript 能够根据你传入的内容推断类型参数。
function doSomething<T>(arg: T) {
// ...
}
// 我们可以显式地说明 'T' 应该是 'string'。
doSomething<string>("hello!");
// 我们也可以让 'T' 的类型被推断。
doSomething("hello!");然而,一个挑战是,并不总是清楚推断的“最佳”类型是什么。 这可能导致 TypeScript 拒绝有效调用、接受有问题的调用,或者在捕获错误时报告更差的错误消息。
例如,让我们想象一个 createStreetLight 函数,它接受一个颜色名称列表,以及一个可选的默认颜色。
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
createStreetLight(["red", "yellow", "green"], "red");当我们传入一个不在原始 colors 数组中的 defaultColor 时会发生什么? 在这个函数中,colors 应该是“真理之源”,并描述可以传递给 defaultColor 的内容。
// 哎呀!这是不希望的,但被允许了!
createStreetLight(["red", "yellow", "green"], "blue");在这个调用中,类型推断决定 "blue" 与 "red"、"yellow" 或 "green" 一样有效。 因此,TypeScript 不是拒绝调用,而是将 C 的类型推断为 "red" | "yellow" | "green" | "blue"。 你可能会说推断结果在我们面前变蓝(双关)了!
目前人们处理这个问题的一种方法是添加一个受现有类型参数约束的单独类型参数。
function createStreetLight<C extends string, D extends C>(colors: C[], defaultColor?: D) {
}
createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// 错误!
// 类型 '"blue"' 的参数不能赋给类型 '"red" | "yellow" | "green" | undefined' 的参数。这有效,但有点笨拙,因为 D 可能不会在 createStreetLight 的签名中的其他地方使用。 虽然在这种情况下不算坏,但在签名中只使用一次类型参数通常是一种代码异味。
这就是为什么 TypeScript 5.4 引入了一个新的 NoInfer<T> 实用类型。 将类型包装在 NoInfer<...> 中会向 TypeScript 发出信号,不要深入挖掘并与内部类型匹配以寻找类型推断的候选。
使用 NoInfer,我们可以将 createStreetLight 重写为类似这样:
function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {
// ...
}
createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// 错误!
// 类型 '"blue"' 的参数不能赋给类型 '"red" | "yellow" | "green" | undefined' 的参数。从推断探索中排除 defaultColor 的类型意味着 "blue" 永远不会成为推断候选,类型检查器可以拒绝它。
你可以在实现拉取请求中看到具体的更改,以及感谢 Mateusz Burzyński 提供的初始实现!
Object.groupBy 和 Map.groupBy
TypeScript 5.4 为 JavaScript 的新静态方法 Object.groupBy 和 Map.groupBy 添加了声明。
Object.groupBy 接受一个可迭代对象和一个决定每个元素应放入哪个“组”的函数。 该函数需要为每个不同的组生成一个“键”,Object.groupBy 使用该键创建一个对象,其中每个键映射到包含原始元素的数组。
所以下面的 JavaScript:
const array = [0, 1, 2, 3, 4, 5];
const myObj = Object.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even": "odd";
});基本上等价于:
const myObj = {
even: [0, 2, 4],
odd: [1, 3, 5],
};Map.groupBy 类似,但生成的是 Map 而不是普通对象。 如果你需要 Map 的保证,处理期望 Map 的 API,或者需要使用任何类型的键进行分组(不仅仅是可以用作 JavaScript 属性名称的键),这可能更可取。
const myObj = Map.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even" : "odd";
});和以前一样,你可以用等效的方式创建 myObj:
const myObj = new Map();
myObj.set("even", [0, 2, 4]);
myObj.set("odd", [1, 3, 5]);注意,在上述 Object.groupBy 的例子中,生成的对象使用了所有可选属性。
interface EvenOdds {
even?: number[];
odd?: number[];
}
const myObj: EvenOdds = Object.groupBy(...);
myObj.even;
// ~~~~
// 在 'strictNullChecks' 下访问此属性会出错。这是因为无法以通用方式保证 所有 键都由 groupBy 生成。
还要注意,这些方法只能通过将 target 配置为 esnext 或调整 lib 设置来访问。 我们期望它们最终将在稳定的 es2024 目标下可用。
我们要感谢 Kevin Gibbons 为这些 groupBy 方法添加了声明。
在 --moduleResolution bundler 和 --module preserve 中支持 require() 调用
TypeScript 有一个名为 bundler 的 moduleResolution 选项,旨在模拟现代打包器确定导入路径指向哪个文件的方式。 该选项的一个限制是它必须与 --module esnext 配对,使得无法使用 import ... = require(...) 语法。
// 以前报错
import myModule = require("module/path");如果你打算只编写标准的 ECMAScript import,这可能看起来不是大问题,但在使用具有条件导出的包时会有区别。
在 TypeScript 5.4 中,当将 module 设置设置为一个名为 preserve 的新选项时,现在可以使用 require()。
在 --module preserve 和 --moduleResolution bundler 之间,两者更准确地模拟了打包器和像 Bun 这样的运行时所允许的内容,以及它们将如何执行模块查找。 实际上,当使用 --module preserve 时,bundler 选项将隐式设置为 --moduleResolution(连同 --esModuleInterop 和 --resolveJsonModule)
{
"compilerOptions": {
"module": "preserve",
// ^ 也隐含:
// "moduleResolution": "bundler",
// "esModuleInterop": true,
// "resolveJsonModule": true,
// ...
}
}在 --module preserve 下,ECMAScript import 将始终按原样输出,而 import ... = require(...) 将输出为 require() 调用(尽管在实践中你可能甚至不使用 TypeScript 进行输出,因为你可能会为代码使用打包器)。 无论包含文件的文件扩展名如何,这都成立。 所以这段代码的输出:
import * as foo from "some-package/foo";
import bar = require("some-package/bar");应该看起来像这样:
import * as foo from "some-package/foo";
var bar = require("some-package/bar");这还意味着你选择的语法决定了如何匹配条件导出。 因此,在上面的例子中,如果 some-package 的 package.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 类型进行检查。 这意味着运行时现在可以更准确地描述导入属性
// 在某个全局文件中。
interface ImportAttributes {
type: "json";
}
// 在另一个模块中
import * as ns from "foo" with { type: "not-json" };
// ~~~~~~~~~~
// 错误!
//
// 类型 '{ type: "not-json"; }' 不能赋给类型 'ImportAttributes'。
// 属性 'type' 的类型不兼容。
// 类型 '"not-json"' 不能赋给类型 '"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 中的第二个变量声明。
type IsArray<T> = T extends any[] ? true : false;
function foo<U extends object>(x: IsArray<U>) {
let first: true = x; // 错误
let second: false = x; // 错误,但以前不是
}以前,当 TypeScript 检查 second 的初始化器时,它需要确定 IsArray<U> 是否可赋值给单元类型 false。 虽然 IsArray<U> 没有明显的兼容方式,但 TypeScript 也会查看该类型的约束。 在条件类型如 T extends Foo ? TrueBranch : FalseBranch 中,其中 T 是泛型,类型系统会查看 T 的约束,将其代入 T 本身,并决定采用 true 分支还是 false 分支。
但这种行为是不准确的,因为它过于急切。 即使 T 的约束不能赋值给 Foo,也不意味着它不会被实例化为可以赋值给 Foo 的东西。 因此,更正确的行为是在无法证明 T 从不 或 总是 扩展 Foo 的情况下,为条件类型的约束生成一个联合类型。
TypeScript 5.4 采用了这种更准确的行为。 这在实践中意味着你可能会开始发现一些条件类型实例不再与它们的分支兼容。
更积极地简化类型变量与原始类型之间的交集
TypeScript 现在更积极地简化类型变量与原始类型之间的交集,具体取决于类型变量的约束与这些原始类型的重叠方式。
declare function intersect<T, U>(x: T, y: U): T & U;
function foo<T extends "abc" | "def">(x: T, str: string, num: number) {
// 之前是 'T & string',现在只是 'T'
let a = intersect(x, str);
// 之前是 'T & number',现在只是 'never'
let b = intersect(x, num)
// 之前是 '(T & "abc") | (T & "def")',现在只是 'T'
let c = Math.random() < 0.5 ?
intersect(x, "abc") :
intersect(x, "def");
}有关更多信息,请在此处查看更改。
改进对带插值的模板字符串的检查
TypeScript 现在更准确地检查字符串是否可以赋值给模板字符串类型的占位符插槽。
function a<T extends {id: string}>() {
let x: `-${keyof T & string}`;
// 以前报错,现在不报错。
x = "-id";
}这种行为更可取,但可能会在使用条件类型等结构的代码中引起中断,在这些结构中这些规则变化很容易观察到。
有关更多详细信息,请参阅此更改。
仅类型导入与本地值冲突时出错
以前,如果对 Something 的导入仅引用类型,TypeScript 会在 isolatedModules 下允许以下代码。
import { 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 修饰符:
import type { Something } from "./some/path";
// 或者
import { type Something } from "./some/path";新的枚举可赋值性限制
当两个枚举具有相同的声明名称和枚举成员名称时,以前它们总是被认为是兼容的; 然而,当值已知时,TypeScript 会静默允许它们具有不同的值。
TypeScript 5.4 通过要求在已知值时值必须相同来收紧此限制。
namespace 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) {
// 两者以前兼容 - 现在不再兼容,
// TypeScript 报错,类似:
//
// 'SomeEnum.B' 的声明在值上不同,期望 '1' 但得到 '2'。
x = y;
y = x;
}此外,当其中一个枚举成员没有静态已知值时,有新的限制。 在这种情况下,另一个枚举必须至少是隐式数字(例如,它没有静态解析的初始化器),或者是显式数字(意味着 TypeScript 可以将值解析为数字)。 实际上,这意味着字符串枚举成员只能与其他具有相同值的字符串枚举兼容。
namespace 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) {
// 两者以前兼容 - 现在不再兼容,
// TypeScript 报错,类似:
//
// 'SomeEnum.B' 的一个值是字符串 '"some known string"',另一个被假定为未知的数字值。
x = y;
y = x;
}有关更多信息,请参阅引入此更改的拉取请求。
枚举成员的名称限制
TypeScript 不再允许枚举成员使用名称 Infinity、-Infinity 或 NaN。
// 以下所有都会报错:
//
// 枚举成员不能有数字名称。
enum E {
Infinity = 0,
"-Infinity" = 1,
NaN = 2,
}改进了带有 any 剩余元素的元组上的映射类型保留
以前,将带有 any 的映射类型应用于元组会创建 any 元素类型。 这是不希望的,现已修复。
Promise.all(["", ...([] as any)])
.then((result) => {
const head = result[0]; // 5.3: any, 5.4: string
const tail = result.slice(1); // 5.3 any, 5.4: any[]
});有关更多信息,请参阅修复以及关于行为变化的后续讨论和进一步调整。
输出更改
虽然本身不是破坏性更改,但开发人员可能隐式依赖 TypeScript 的 JavaScript 或声明输出。 以下是值得注意的更改。