【译】TypeScript 5.4测试版发布

最后一次赋值后,闭包中保留了收缩的范围

TypeScript通常可以根据您执行的检查推断出变量的更具体类型。这个过程称为收缩。

1
2
3
4
5
6
function uppercaseStrings(x: string | number) {
if (typeof x === "string") {
// TypeScript在这里知道'x'是一个'string'。
return x.toUpperCase();
}
}

一个常见的问题是这些收缩的类型并不总是在函数闭包中保留。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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可以安全地从包含函数的外部收缩范围。这意味着上面的示例现在可以正常工作了。

请注意,如果变量在嵌套函数中的任何地方赋值,收缩分析就不会起作用。这是因为无法确定该函数是否稍后会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 能够从您传递的任何内容中推断类型参数。

1
2
3
4
5
6
7
8
9
10
function doSomething<T>(arg: T) {
// ...
}


// 我们可以明确表示'T'应该是'string'。
doSomething<string>("hello!");

// 我们也可以让'T'的类型被推断。
doSomething("hello!");

然而,一个挑战是,并不总是清楚什么是最好的类型来推断。这可能导致 TypeScript 拒绝有效的调用,接受可疑的调用,或者在捕获到错误时提供更糟糕的错误消息。

例如,让我们想象一个 createStreetLight 函数,它接受一个颜色名称列表,以及一个可选的默认颜色。

1
2
3
4
5
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}

createStreetLight(["red", "yellow", "green"], "red");

当我们传入一个原始颜色数组中不存在的 defaultColor 时会发生什么?在这个函数中,colors 应该是“真实的来源”并描述可以传递给defaultColor的内容。

1
2
// 糟糕!这是不希望的,但是允许的!
createStreetLight(["red", "yellow", "green"], "blue");

在这个调用中,类型推断决定"blue""red""yellow""green"一样有效。所以 TypeScript 不会拒绝该调用,而是推断类型 C 为"red" | "yellow" | "green" | "blue"。你可能会说,这个推论让我们颜面无光!

人们目前处理这个问题的一种方法是添加一个由现有类型参数限定的单独类型参数。

1
2
3
4
5
6
7
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 重写为类似这样的东西:

1
2
3
4
5
6
7
8
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.groupByMap.groupBy静态方法的声明。

Object.groupBy 接受一个可迭代对象和一个函数,该函数决定每个元素应该放入哪个“组”。该函数需要为每个不同的组制作一个“键”,而 Object.groupBy 则使用该键来创建一个对象,其中每个键映射到包含原始元素的数组。

因此,以下 JavaScript 代码:

1
2
3
4
5
const array = [0, 1, 2, 3, 4, 5];

const myObj = Object.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even": "odd";
});

基本上等同于编写:

1
2
3
4
const myObj = {
even: [0, 2, 4],
odd: [1, 3, 5],
};

Map.groupBy 类似,但生成一个 Map 而不是普通对象。如果您需要Map的保证、处理预期为 Map API,或者需要使用任何类型的键进行分组——而不仅仅是 JavaScript 中可用作属性名称的键,那么这可能更可取。

1
2
3
const myObj = Map.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even" : "odd";
});

就像之前一样,您可以以等效的方式创建 myObj

1
2
3
4
const myObj = new Map();

myObj.set("even", [0, 2, 4]);
myObj.set("odd", [1, 3, 5]);

请注意,在上述 Object.groupBy 示例中,生成的对象使用了所有可选属性。

1
2
3
4
5
6
7
8
9
10
interface EvenOdds {
even?: number[];
odd?: number[];
}

const myObj: EvenOdds = Object.groupBy(...);

myObj.even;
// ~~~~
// 在 'strictNullChecks' 下访问此处会报错。

这是因为在一般情况下无法保证所有键都由 groupBy 生成。

还请注意,只有通过配置目标为 esnext 或调整lib设置才能访问这些方法。我们预计它们最终将在稳定的 es2024 目标下可用。

我们要感谢 Kevin Gibbons 添加了这些 groupBy 方法的声明。

--moduleResolution bundler--module preserverequire() 调用的支持

TypeScript 有一个名为 bundlermoduleResolution 选项,旨在模拟现代打包工具确定导入路径所指向的文件的方式。该选项的一个限制是它必须与 --module esnext 配对使用,这使得无法使用 import ... = require(...) 语法。

1
2
// 以前会报错
import myModule = require("module/path");

如果您打算只编写标准的ECMAScript导入,这可能看起来并不重要,但是在使用具有条件导出的包时就会有所不同。

TypeScript 5.4 中,当将 module 设置为一个名为 preserve 的新选项时,现在可以使用 require()

--module preserve--moduleResolution bundler 之间,两者更准确地模拟了像 Bun 这样的打包工具和运行时会允许什么,并且它们将执行模块查找的方式。事实上,当使用 --module preserve 时,bundler 选项将隐式设置为 --moduleResolution(以及 --esModuleInterop --resolveJsonModule)。

1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"module": "preserve",
// ^ 还意味着:
// "moduleResolution": "bundler",
// "esModuleInterop": true,
// "resolveJsonModule": true,

// ...
}
}

--module preserve 下,ECMAScript 导入将始终按原样输出,并且 import ... = require(...) 将被输出为require()调用(尽管实际上您可能根本不会使用 TypeScript 进行输出,因为很可能您将使用打包工具对您的代码进行打包)。无论包含文件的扩展名是什么,这一点都成立。因此,此代码的输出:

1
2
import * as foo from "some-package/foo";
import bar = require("some-package/bar");

应该看起来像这样:

1
2
import * as foo from "some-package/foo";
var bar = require("some-package/bar");

这也意味着您选择的语法将指导条件导出的匹配方式。因此,在上面的示例中,如果 some-packagepackage.json 如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"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 类型进行检查。这意味着运行时现在可以更准确地描述导入属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在某个全局文件中。
interface ImportAttributes {
type: "json";
}

// 在某个其他模块中
import * as ns from "foo" with { type: "not-json" };
// ~~~~~~~~~~
// 错误!
//
// 类型'{ type: "not-json"; }'不能分配给类型'ImportAttributes'。
// 属性类型不兼容。
// 类型'"not-json"'不能分配给类型'"json"'。

这个变化是由 Oleksandr Tarasiuk 提供的。

添加缺少参数的快速修复

TypeScript 现在有了一个快速修复,可以向调用参数过多的函数中添加新参数。

someFunction 调用 someHelperFunction 比预期参数多 2 个时,会提供一个快速修复。

在应用快速修复之后,缺少的参数已经添加到 someHelperFunction 中。

当通过多个现有函数传递新参数时,这可能会很有用,这在当前情况下可能会很繁琐。

这个快速修复是由 Oleksandr Tarasiuk 提供的。

TypeScript 5.0 即将弃用

TypeScript 5.0 弃用了以下选项和行为:

  • target: ES3
  • noImplicitUseStrict
  • keyofStringsOnly
  • suppressExcessPropertyErrors
  • suppressImplicitAnyIndexErrors
  • noStrictGenericChecks
  • charset
  • out
  • 在项目引用中的 prepend
  • 隐式的 OS 特定的 newLine

为了继续使用它们,使用 TypeScript 5.0 和其他更新版本的开发人员必须指定一个名为 ignoreDeprecations 的新选项,其值为 "5.0"

然而,TypScript 5.4 将是这些选项继续正常工作的最后一个版本。到 TypeScript 5.5(可能是 2024 年 6 月),这些选项将变为硬错误,使用它们的代码将需要迁移。

破坏性更改

lib.d.ts 更改

DOM 生成的类型可能会影响您的代码库。有关 TypeScript 5.4 DOM 更新的更多信息,请参阅此处

更准确的条件类型约束

在函数 foo 中,以下代码不再允许第二个变量声明。

1
2
3
4
5
6
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 现在更积极地减少类型变量与原始类型之间的交集,具体取决于类型变量的约束与这些原始类型的重叠程度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 现在更准确地检查字符串是否可分配给模板字符串类型的占位符位置。

1
2
3
4
5
6
function a<T extends {id: string}>() {
let x: `-${keyof T & string}`;

// 以前报错,现在不再报错。
x = "-id";
}

这种行为更可取,但可能会导致使用诸如条件类型之类的构造的代码中断。

有关更多详细信息,请参阅此更改

当类型仅导入与本地值冲突时出现错误

以前,如果 Something 的导入仅引用了一个类型,在 isolatedModules 下,TypeScript 将允许以下代码。

1
2
3
import { Something } from "./some/path";

let Something = 123;

但是,单文件编译器无法安全地假设是否“安全”删除导入,即使代码在运行时肯定会失败。在 TypeScript 5.4 中,这段代码将触发如下错误:

1
Import 'Something' conflicts with local value, so must be declared with a type-only import when 'isolatedModules' is enabled.

修复应该是要么进行本地重命名,要么按照错误中所述,向导入添加类型修饰符:

1
2
3
4
5
import type { Something } from "./some/path";

// 或者

import { type Something } from "./some/path";

有关更改本身的更多信息,请参见此处

提交更改

虽然不是一个破坏性的变化,开发人员可能已经隐式地依赖于 TypeScript JavaScript 或声明发射输出。以下是值得注意的更改。

  • 更经常地保留类型参数名称当被阴影化时
  • 将异步函数的复杂参数列表移动到下级生成器主体中
  • 不要删除函数声明中的绑定别名
  • ImportTypeNode 中的 ImportAttributes 应该经历相同的提交阶段

接下来是什么?

到目前为止,TypeScript 5.4 是我们所说的“功能稳定”。TypeScript 5.4 的重点将是bug修复、优化和某些低风险的编辑器功能。我们将在一个多月内提供一个发行候选版本,随后很快发布一个稳定版本。如果您有兴趣规划发布,请务必关注我们的迭代计划,其中包含目标发布日期等信息。

请注意:虽然 beta 是尝试 TypeScript 下一个版本的好方法,但您也可以尝试夜间版本来获取最新的 TypeScript 5.4 版本,直到我们发布候选版本为止。我们的夜间版本经过了充分测试,甚至可以仅在您的编辑器中进行测试。