【译】TypeScript 5.5发布
如果你不熟悉 TypeScript
,它是一种在 JavaScript
基础上构建的语言,使得声明和描述类型成为可能。在代码中编写类型可以让我们解释意图,并使用其他工具检查代码以捕捉诸如拼写错误、null
和 undefined
问题等错误。类型还为 TypeScript
的编辑器工具提供支持,例如自动完成、代码导航和重构功能,你可以在 Visual Studio
和 VS Code
等编辑器中看到这些功能。事实上,如果你在这些编辑器中编写 JavaScript
,这种体验也是由 TypeScript
提供的!你可以在 TypeScript 网站上了解更多信息。
通过 npm 使用 TypeScript 的入门命令如下:
npm install -D typescript
以下是 TypeScript 5.5 中的新功能列表!
- 推断的类型谓词
- 常量索引访问的控制流缩小
- JSDoc 的 @import 标签
- 正则表达式语法检查
- 对新的 ECMAScript Set 方法的支持
- 独立的声明
- 配置文件的 ${configDir} 模板变量
- 咨询 package.json 依赖项以生成声明文件
- 编辑器和观察模式的可靠性改进
- 性能和尺寸优化
- 从 ECMAScript 模块更容易地消费 API
- transpileDeclaration API
- 显著的行为变化
- 禁用 TypeScript 5.0 中弃用的功能
- lib.d.ts 更改
- 更严格的装饰器解析
- undefined 不再是一个可定义的类型名称
- 简化的引用指令声明发射
- Beta 和 RC 以来的新变化
自 beta 版以来,我们做了一些想要强调的更改。
首先,我们添加了对ECMAScript
新的 Set
方法的支持。此外,我们调整了 TypeScript 新的正则表达式检查的行为,使其稍微宽松一些,同时仍然在可疑的转义字符上报错,这些字符仅在ECMAScript
附录 B 中允许。
我们还添加并记录了更多的性能优化:尤其是在 transpileModule
中跳过检查以及在 TypeScript 过滤上下文类型时的优化。这些优化可以在许多常见场景中加快构建和迭代时间。
自发布候选版本(RC)
以来,我们暂时恢复了通过 package.json
确定给定文件的模块格式的新工作。我们收到了反馈,这一变化破坏了某些工作流程,并导致了较大项目中意外的文件监视压力。在TypeScript 5.6
中,我们希望带回这个功能的更细化版本,同时我们还会研究如何优化对不存在文件的监视。
推断的类型谓词
这一部分由 Dan Vanderkam 编写,他在 TypeScript 5.5 中实现了这个功能。感谢 Dan!
TypeScript
的控制流分析在跟踪变量类型随着代码的变化方面做得很好:
interface Bird {
commonName: string;
scientificName: string;
sing(): void;
}
// 将国家名称映射到国鸟。
// 并非所有国家都有官方鸟类(例如加拿大)。
declare const nationalBirds: Map<string, Bird>;
function makeNationalBirdCall(country: string) {
const bird = nationalBirds.get(country); // bird 的声明类型为 Bird | undefined
if (bird) {
bird.sing(); // 在 if 语句内部,bird 的类型为 Bird
} else {
// 在这里,bird 的类型为 undefined。
}
}
通过让你处理 undefined 情况,TypeScript 促使你编写更健壮的代码。
过去,这种类型的细化更难应用于数组。在 TypeScript 的所有早期版本中,这都会是一个错误:
function makeBirdCalls(countries: string[]) {
// birds: (Bird | undefined)[]
const birds = countries
.map(country => nationalBirds.get(country))
.filter(bird => bird !== undefined);
for (const bird of birds) {
bird.sing(); // 错误:'bird' 可能是 'undefined'。
}
}
这段代码完全没问题:我们已经将所有 undefined 值从列表中过滤掉了。但是 TypeScript 无法跟踪到这一点。
在 TypeScript 5.5 中,类型检查器可以接受这段代码:
function makeBirdCalls(countries: string[]) {
// birds: Bird[]
const birds = countries
.map(country => nationalBirds.get(country))
.filter(bird => bird !== undefined);
for (const bird of birds) {
bird.sing(); // 可以!
}
}
注意 birds 的更精确类型。
这是因为 TypeScript 现在为 filter 函数推断了一个类型谓词。你可以通过将其提取到一个独立的函数中更清楚地看到发生了什么:
// 函数 isBirdReal(bird: Bird | undefined): bird is Bird
function isBirdReal(bird: Bird | undefined) {
return bird !== undefined;
}
bird is Bird
是类型谓词。它的意思是,如果函数返回 true,那么它就是一个 Bird(如果函数返回 false,那么它就是 undefined)。Array.prototype.filter 的类型声明知道类型谓词,因此最终结果是你得到一个更精确的类型,并且代码通过了类型检查。
如果以下条件成立,TypeScript 将推断一个函数返回一个类型谓词:
- 函数没有显式的返回类型或类型谓词注解。
- 函数只有一个 return 语句,并且没有隐式返回。
- 函数不修改其参数。
- 函数返回一个与参数上的细化相关的布尔表达式。
通常,这会按你预期的方式工作。以下是一些推断类型谓词的更多示例:
// const isNumber: (x: unknown) => x is number
const isNumber = (x: unknown) => typeof x === 'number';
// const isNonNullish: <T>(x: T) => x is NonNullable<T>
const isNonNullish = <T,>(x: T) => x != null;
以前,TypeScript 只会推断这些函数返回 boolean。现在它推断具有类型谓词的签名,如 x is number
或 x is NonNullable<T>
。
类型谓词具有“如果且仅如果”的语义。如果一个函数返回 x is T
,则意味着:
- 如果函数返回 true,那么 x 的类型为 T。
- 如果函数返回 false,那么 x 的类型不是 T。
如果你期望推断出类型谓词但没有,那么你可能违反了第二条规则。这通常出现在“真实”检查中:
function getClassroomAverage(students: string[], allScores: Map<string, number>) {
const studentScores = students
.map(student => allScores.get(student))
.filter(score => !!score);
return studentScores.reduce((a, b) => a + b) / studentScores.length;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 错误:对象可能是 'undefined'。
}
TypeScript 没有为 score => !!score
推断类型谓词,这是正确的:如果它返回 true,那么 score 是一个 number。但如果它返回 false,那么 score 可能是 undefined 或 number(特别是 0)。这是真实的错误:如果任何学生得了 0 分,过滤掉他们的成绩会导致平均值向上偏移。更少的人会高于平均值,更多的人会感到沮丧!
与第一个示例一样,最好显式过滤掉 undefined 值:
function getClassroomAverage(students: string[], allScores: Map<string, number>) {
const studentScores = students
.map(student => allScores.get(student))
.filter(score => score !== undefined);
return studentScores.reduce((a, b) => a + b) / studentScores.length; // 可以!
}
对象类型的真实检查将推断类型谓词,其中没有歧义。记住,函数必须返回布尔值才能成为推断类型谓词的候选者:x => !!x
可能会推断类型谓词,但 x => x
绝对不会。
显式类型谓词继续按之前的方式工作。TypeScript 不会检查是否会推断相同的类型谓词。显式类型谓词(“is”)与类型断言(“as”)一样不安全。
如果 TypeScript 现在推断出比你想要的更精确的类型,这个功能可能会破坏现有代码。例如:
// 以前,nums: (number | null)[]
// 现在,nums: number[]
const nums = [1, 2, 3, null, 5].filter(x => x !== null);
nums.push(null); // 在 TS 5.4 中可以,在 TS 5.5 中错误
解决方法是使用显式类型注解告诉 TypeScript 你想要的类型:
const nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null);
nums.push(null); // 在所有版本中都可以
有关更多信息,请查看实现的 pull request 和 Dan 关于实现此功能的博客文章。
常量索引访问的控制流缩小
TypeScript 现在能够缩小形如 obj[key]
的表达式,当 obj
和 key
都是有效的常量时。
function f1(obj: Record<string, unknown>, key: string) {
if (typeof obj[key] === "string") {
// 现在可以了,之前会报错
obj[key].toUpperCase();
}
}
在上面的例子中,obj
和 key
都没有被修改过,所以 TypeScript 可以在 typeof
检查之后将 obj[key]
的类型缩小为 string
。有关更多信息,请参见此功能的实现 pull request。
JSDoc 的 @import 标签
目前,如果你想在 JavaScript 文件中仅为了类型检查而导入某些内容,这是非常麻烦的。如果在运行时不存在名为 SomeType
的类型,JavaScript 开发者不能简单地导入它。
// ./some-module.d.ts
export interface SomeType {
// ...
}
// ./index.js
import { SomeType } from "./some-module"; // ❌ 运行时错误!
/**
* @param {SomeType} myValue
*/
function doSomething(myValue) {
// ...
}
SomeType
在运行时不存在,因此导入会失败。开发者可以改用命名空间导入。
import * as someModule from "./some-module";
/**
* @param {someModule.SomeType} myValue
*/
function doSomething(myValue) {
// ...
}
但是,./some-module
仍然在运行时被导入——这可能也不是你想要的。
为了避免这种情况,开发者通常不得不在 JSDoc 注释中使用 import(...)
类型。
/**
* @param {import("./some-module").SomeType} myValue
*/
function doSomething(myValue) {
// ...
}
如果你想在多个地方重复使用相同的类型,可以使用 typedef 来避免重复导入。
/**
* @typedef {import("./some-module").SomeType} SomeType
*/
/**
* @param {SomeType} myValue
*/
function doSomething(myValue) {
// ...
}
这有助于本地使用 SomeType
,但对于许多导入来说,这可能会有些冗长。
这就是为什么 TypeScript 现在支持一个新的 @import
注释标签,它具有与 ECMAScript 导入相同的语法。
/** @import { SomeType } from "some-module" */
/**
* @param {SomeType} myValue
*/
function doSomething(myValue) {
// ...
}
在这里,我们使用了命名导入。我们也可以将导入写成命名空间导入。
/** @import * as someModule from "some-module" */
/**
* @param {someModule.SomeType} myValue
*/
function doSomething(myValue) {
// ...
}
由于这些只是 JSDoc 注释,它们不会影响运行时行为。
我们要对贡献此更改的 Oleksandr Tarasiuk 表示感谢!
正则表达式语法检查
到目前为止,TypeScript 通常会跳过代码中的大多数正则表达式。这是因为正则表达式在技术上有一个可扩展的语法,并且 TypeScript 从未尝试将正则表达式编译为更早版本的 JavaScript。尽管如此,这意味着许多常见问题在正则表达式中不会被发现,它们要么会在运行时变成错误,要么会悄无声息地失败。
但现在,TypeScript 对正则表达式进行基本的语法检查!
let myRegex = /@robot(\s+(please|immediately)))? do some task/;
// ~
// 错误!
// 意外的 ')'。你是想用反斜杠对其进行转义吗?
这是一个简单的例子,但这种检查可以捕捉到许多常见错误。实际上,TypeScript 的检查略微超出了语法检查。例如,TypeScript 现在可以捕捉到不存在的反向引用问题。
let myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u;
// ~
// 错误!
// 这个反向引用引用了一个不存在的组。
// 这个正则表达式中只有 2 个捕获组。
同样的适用于命名捕获组。
let myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/;
// ~~~~~~~~~~~
// 错误!
// 在这个正则表达式中没有名为 'namedImport' 的捕获组。
TypeScript 的检查现在也知道何时使用超出目标 ECMAScript 版本的新功能的 RegExp 特性。例如,如果我们在 ES5 目标中使用上面的命名捕获组,我们会收到错误。
let myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<importedEntity>/;
// ~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
// 错误!
// 命名捕获组仅在目标为 'ES2018' 或更高版本时可用。
某些正则表达式标志也是如此。
请注意,TypeScript 的正则表达式支持仅限于正则表达式文本。如果你尝试使用字符串文本调用 new RegExp
,TypeScript 将不会检查提供的字符串。
我们要感谢 GitHub 用户 graphemecluster,他与我们进行了大量迭代,使这个功能得以进入 TypeScript。
支持新的 ECMAScript Set 方法
TypeScript 5.5 声明了 ECMAScript Set 类型的新提议方法。
其中一些方法,如 union
、intersection
、difference
和 symmetricDifference
,接受另一个 Set 并返回一个新的 Set 作为结果。其他方法,如 isSubsetOf
、isSupersetOf
和 isDisjointFrom
,接受另一个 Set 并返回一个布尔值。这些方法都不会修改原始的 Set。
以下是如何使用这些方法及其行为的一个简短示例:
let fruits = new Set(["apples", "bananas", "pears", "oranges"]);
let applesAndBananas = new Set(["apples", "bananas"]);
let applesAndOranges = new Set(["apples", "oranges"]);
let oranges = new Set(["oranges"]);
let emptySet = new Set();
////
// union
////
// Set(4) {'apples', 'bananas', 'pears', 'oranges'}
console.log(fruits.union(oranges));
// Set(3) {'apples', 'bananas', 'oranges'}
console.log(applesAndBananas.union(oranges));
////
// intersection
////
// Set(2) {'apples', 'bananas'}
console.log(fruits.intersection(applesAndBananas));
// Set(0) {}
console.log(applesAndBananas.intersection(oranges));
// Set(1) {'apples'}
console.log(applesAndBananas.intersection(applesAndOranges));
////
// difference
////
// Set(3) {'apples', 'bananas', 'pears'}
console.log(fruits.difference(oranges));
// Set(2) {'pears', 'oranges'}
console.log(fruits.difference(applesAndBananas));
// Set(1) {'bananas'}
console.log(applesAndBananas.difference(applesAndOranges));
////
// symmetricDifference
////
// Set(2) {'bananas', 'oranges'}
console.log(applesAndBananas.symmetricDifference(applesAndOranges)); // 没有 apples
////
// isDisjointFrom
////
// true
console.log(applesAndBananas.isDisjointFrom(oranges));
// false
console.log(applesAndBananas.isDisjointFrom(applesAndOranges));
// true
console.log(fruits.isDisjointFrom(emptySet));
// true
console.log(emptySet.isDisjointFrom(emptySet));
////
// isSubsetOf
////
// true
console.log(applesAndBananas.isSubsetOf(fruits));
// false
console.log(fruits.isSubsetOf(applesAndBananas));
// false
console.log(applesAndBananas.isSubsetOf(oranges));
// true
console.log(fruits.isSubsetOf(fruits));
// true
console.log(emptySet.isSubsetOf(fruits));
////
// isSupersetOf
////
// true
console.log(fruits.isSupersetOf(applesAndBananas));
// false
console.log(applesAndBananas.isSupersetOf(fruits));
// false
console.log(applesAndBananas.isSupersetOf(oranges));
// true
console.log(fruits.isSupersetOf(fruits));
// false
console.log(emptySet.isSupersetOf(fruits));
我们要感谢 Kevin Gibbons,他不仅共同推动了 ECMAScript 中此功能的实现,还为 TypeScript 提供了 Set、ReadonlySet 和 ReadonlySetLike 的声明!
独立声明
这一部分由 Rob Palmer 共同编写,他支持独立声明的设计。
声明文件(即 .d.ts 文件)描述现有库和模块的形状,以便 TypeScript 可以高效地检查你对库的使用,而不需要分析库本身。虽然可以手写声明文件,但如果你正在编写类型化代码,最好还是让 TypeScript 使用 --declaration
自动从源文件生成它们。
TypeScript 编译器及其 API 一直负责生成声明文件;然而,有些情况下你可能希望使用其他工具,或者传统的构建过程无法扩展。
用例:更快的声明生成工具
假设你想创建一个更快的工具来生成声明文件,也许是作为发布服务或新打包器的一部分。虽然有一个蓬勃发展的生态系统,可以将 TypeScript 转换为 JavaScript,但将 TypeScript 转换为声明文件的情况却并非如此。这是因为 TypeScript 的类型推断允许我们编写不明确声明类型的代码,这意味着声明生成可能很复杂。
考虑一个简单的示例,即一个添加两个导入变量的函数。
// util.ts
export let one = "1";
export let two = "2";
// add.ts
import { one, two } from "./util";
export function add() { return one + two; }
即使我们只想生成 add.d.ts
,TypeScript 也需要爬入另一个导入文件(util.ts
),推断出 one
和 two
的类型是字符串,然后计算出两个字符串上的 +
操作符会导致一个字符串返回类型。
// add.d.ts
export declare function add(): string;
虽然这种推断对开发者体验很重要,但这意味着要生成声明文件的工具需要复制部分类型检查器,包括推断和解析模块说明符的能力。
用例:并行声明生成和并行检查
假设你有一个包含许多项目的 monorepo 和一个多核 CPU,它希望能帮助你更快地检查代码。如果我们能同时检查所有这些项目,运行每个项目在不同的核心上,那不是很好吗?
不幸的是,我们不能完全自由地并行处理所有工作。原因是我们必须按依赖顺序构建这些项目,因为每个项目都在检查其依赖项的声明文件。因此我们必须先构建依赖项以生成声明文件。TypeScript 的项目引用功能也是如此,以"拓扑"依赖顺序构建项目集。
例如,如果我们有两个项目,分别是 backend
和 frontend
,它们都依赖于一个名为 core
的项目,TypeScript 不能在 core
构建完成并生成其声明文件之前开始检查 frontend
或 backend
。
在上面的图中,你可以看到我们有一个瓶颈。虽然我们可以并行构建 frontend
和 backend
,但我们需要先等待 core
完成构建,然后才能开始任何一个项目。
我们如何改进这一点呢?如果一个快速的工具能够并行生成 core
的所有声明文件,TypeScript 就可以立即随之并行检查 core
、frontend
和 backend
。
解决方案:显式类型!
这两个用例的共同需求是我们需要一个跨文件类型检查器来生成声明文件。这对工具社区来说是一个很大的要求。
作为一个更复杂的示例,如果我们想为以下代码生成声明文件:
import { add } from "./add";
const x = add();
export function foo() {
return x;
}
我们需要为 foo
生成一个签名。这需要查看 foo
的实现。foo
只是返回 x
,所以获取 x
的类型需要查看 add
的实现。但这可能需要查看 add
的依赖项的实现,依此类推。我们在这里看到的是,生成声明文件需要大量逻辑来确定不同地方的类型,这些地方甚至可能不在当前文件中。
尽管如此,对于希望快速迭代和完全并行构建的开发者来说,还有另一种思考这个问题的方法。声明文件只需要模块公共 API 的类型——换句话说,就是导出内容的类型。如果开发者愿意显式写出他们导出的内容的类型,工具就可以在不需要查看模块实现的情况下生成声明文件——也无需重新实现完整的类型检查器。
这就是新的 --isolatedDeclarations
选项的用武之地。--isolatedDeclarations
会报告在没有类型检查器的情况下无法可靠转换模块的错误。更明确地说,它会使 TypeScript 报告如果你有一个文件在其导出部分没有充分注释时的错误。
这意味着在上面的例子中,我们会看到如下错误:
export function foo() {
// ~~~
// 错误!函数必须有一个显式的返回类型注释,使用 `--isolatedDeclarations`。
return x;
}