类型谓词推断
本节由 Dan Vanderkam 编写,他在 TypeScript 5.5 中实现了此特性。感谢 Dan!
TypeScript 的控制流分析在跟踪变量类型随代码执行而变化方面表现出色。
tsxinterface Bird {commonName: string;scientificName: string;sing(): void;}// Maps country names -> national bird.// Not all nations have official birds (looking at you, Canada!)declare const nationalBirds: Map<string, Bird>;function makeNationalBirdCall(country: string) {const bird = nationalBirds.get(country); // bird has a declared type of Bird | undefinedif (bird) {bird.sing(); // bird has type Bird inside the if statement} else {// bird has type undefined here.}}
通过强制处理 undefined 的情况,TypeScript 推动你编写更健壮的代码。
在过去,这种类型细化更难应用于数组。在 TypeScript 的所有旧版本中,这都会导致错误。
tsxfunction 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(); // error: 'bird' is possibly 'undefined'.}}
这段代码完全没有问题:我们已经从列表中过滤掉了所有 undefined 值。但 TypeScript 以前无法跟上这一逻辑。
在 TypeScript 5.5 中,类型检查器可以正常处理这段代码。
tsxfunction makeBirdCalls(countries: string[]) {// birds: Bird[]const birds = countries.map(country => nationalBirds.get(country)).filter(bird => bird !== undefined);for (const bird of birds) {bird.sing(); // ok!}}
请注意 birds 更精确的类型。
之所以有效,是因为 TypeScript 现在为 filter 函数推断出了一个类型谓词。将其提取为独立函数可以更清晰地看出发生了什么:
tsx// function isBirdReal(bird: Bird | undefined): bird is Birdfunction isBirdReal(bird: Bird | undefined) {return bird !== undefined;}
bird is Bird 就是类型谓词。它的意思是,如果函数返回 true,那么它就是 Bird(如果函数返回 false,则它是 undefined)。Array.prototype.filter 的类型声明能够识别类型谓词,因此最终结果是你得到了更精确的类型,并且代码通过了类型检查。
如果满足以下条件,TypeScript 将推断函数返回一个类型谓词:
- 函数没有显式的返回类型或类型谓词注解。
- 函数有一个单一的
return语句,且没有隐式返回。 - 函数没有修改其参数。
- 函数返回一个与参数细化相关的
boolean表达式。
通常情况下,这符合预期。以下是类型谓词推断的更多示例。
tsx// const isNumber: (x: unknown) => x is numberconst 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类型。
如果你期望推断出类型谓词但并未如愿,那么你可能触犯了第二条规则。这在“真值”(truthiness)检查中经常出现。
tsxfunction 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;// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~// error: Object is possibly 'undefined'.}
TypeScript 没有为 score => !!score 推断出类型谓词,这是正确的:如果返回 true,则 score 是一个 number。但如果返回 false,则 score 可能是 undefined 或 number(具体来说是 0)。这是一个真正的 Bug:如果任何学生考试得分为零,那么过滤掉他们的分数会使平均分偏高。低于平均水平的人会减少,更多的人会感到难过!
与第一个示例一样,最好显式过滤掉 undefined 值。
tsxfunction 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; // ok!}
真值检查确实会为对象类型推断出类型谓词,因为不存在歧义。请记住,函数必须返回 boolean 才有资格被推断出类型谓词:x => !!x 可能会推断出类型谓词,但 x => x 绝对不会。
显式类型谓词的使用方式保持不变。TypeScript 不会检查它是否会推断出相同的类型谓词。显式类型谓词(“is”)并不比类型断言(“as”)更安全。
如果 TypeScript 现在推断出的类型比你想要的更精确,这一特性可能会破坏现有代码。例如:
tsx// Previously, nums: (number | null)[]// Now, nums: number[]const nums = [1, 2, 3, null, 5].filter(x => x !== null);nums.push(null); // ok in TS 5.4, error in TS 5.5
解决方法是使用显式类型注解告诉 TypeScript 你想要的类型。
tsxconst nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null);nums.push(null); // ok in all versions
更多信息,请参阅实现此功能的 Pull Request 以及 Dan 关于实现此功能的博文。
针对常量索引访问的控制流细化
当 obj 和 key 都是常量时,TypeScript 现在能够细化 obj[key] 形式的表达式类型。
tsfunction f1(obj: Record<string, unknown>, key: string) {if (typeof obj[key] === "string") {// Now okay, previously was errorobj[key].toUpperCase();}}
在上述代码中,obj 和 key 都未被修改,因此 TypeScript 可以在 typeof 检查后将 obj[key] 的类型细化为 string。更多信息,请参阅此处实现的 Pull Request。
JSDoc @import 标签
目前,如果只想在 JavaScript 文件中导入某些内容进行类型检查,操作比较麻烦。如果 SomeType 在运行时不存在,JavaScript 开发者无法简单地导入它。
js// ./some-module.d.tsexport interface SomeType {// ...}// ./index.jsimport { SomeType } from "./some-module"; // ❌ runtime error!/*** @param {SomeType} myValue*/function doSomething(myValue) {// ...}
SomeType 在运行时不存在,因此导入会失败。开发者可以使用命名空间导入来替代。
jsimport * as someModule from "./some-module";/*** @param {someModule.SomeType} myValue*/function doSomething(myValue) {// ...}
但 ./some-module 仍然会在运行时被导入——这可能不是我们想要的。
为了避免这种情况,开发者通常不得不在 JSDoc 注释中使用 import(...) 类型。
js/*** @param {import("./some-module").SomeType} myValue*/function doSomething(myValue) {// ...}
如果想在多个地方重用同一个类型,可以使用 typedef 来避免重复导入。
js/*** @typedef {import("./some-module").SomeType} SomeType*//*** @param {SomeType} myValue*/function doSomething(myValue) {// ...}
这有助于在局部使用 SomeType,但对于大量导入来说显得重复且冗长。
这就是为什么 TypeScript 现在支持一种新的 @import 注释标签,它具有与 ECMAScript 导入相同的语法。
js/** @import { SomeType } from "some-module" *//*** @param {SomeType} myValue*/function doSomething(myValue) {// ...}
这里使用了命名导入。我们也可以将其编写为命名空间导入。
js/** @import * as someModule from "some-module" *//*** @param {someModule.SomeType} myValue*/function doSomething(myValue) {// ...}
因为这些只是 JSDoc 注释,它们完全不会影响运行时行为。
我们要向贡献了此更改的 Oleksandr Tarasiuk 表示衷心感谢!
正则表达式语法检查
在此之前,TypeScript 通常会跳过代码中的大多数正则表达式。这是因为正则表达式在技术上具有可扩展的语法,且 TypeScript 从未尝试将正则表达式编译为旧版本的 JavaScript。然而,这意味着正则表达式中许多常见问题无法被发现,最终要么导致运行时错误,要么静默失败。
但现在 TypeScript 会对正则表达式进行基本的语法检查!
tslet myRegex = /@robot(\s+(please|immediately)))? do some task/;// ~// error!// Unexpected ')'. Did you mean to escape it with backslash?
这是一个简单的例子,但这种检查可以捕获许多常见的错误。事实上,TypeScript 的检查甚至超越了语法检查。例如,TypeScript 现在可以捕获关于不存在的反向引用的问题。
tslet myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u;// ~// error!// This backreference refers to a group that does not exist.// There are only 2 capturing groups in this regular expression.
命名捕获组的情况也是如此。
tslet myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/;// ~~~~~~~~~~~// error!// There is no capturing group named 'namedImport' in this regular expression.
TypeScript 的检查现在还能识别何时使用了比当前目标 ECMAScript 版本更新的 RegExp 特性。例如,如果我们像上面那样在 ES5 目标中使用命名捕获组,将会收到错误提示。
tslet myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<importedEntity>/;// ~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~// error!// Named capturing groups are only available when targeting 'ES2018' or later.
某些正则表达式标志也是如此。
请注意,TypeScript 对正则表达式的支持仅限于正则表达式字面量。如果尝试使用字符串字面量调用 new RegExp,TypeScript 不会检查提供的字符串。
我们要感谢 GitHub 用户 graphemecluster,他与我们进行了多次迭代,最终将此功能引入 TypeScript。
支持新的 ECMAScript Set 方法
TypeScript 5.5 声明了 ECMAScript Set 类型的新提案方法。
其中一些方法,如 union、intersection、difference 和 symmetricDifference,接收另一个 Set 并返回一个新的 Set 作为结果。其他方法,如 isSubsetOf、isSupersetOf 和 isDisjointFrom,接收另一个 Set 并返回一个 boolean。这些方法均不会修改原始 Set。
这是一个关于如何使用这些方法及其行为的简单示例:
tslet 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)); // no apples////// isDisjointFrom////// trueconsole.log(applesAndBananas.isDisjointFrom(oranges));// falseconsole.log(applesAndBananas.isDisjointFrom(applesAndOranges));// trueconsole.log(fruits.isDisjointFrom(emptySet));// trueconsole.log(emptySet.isDisjointFrom(emptySet));////// isSubsetOf////// trueconsole.log(applesAndBananas.isSubsetOf(fruits));// falseconsole.log(fruits.isSubsetOf(applesAndBananas));// falseconsole.log(applesAndBananas.isSubsetOf(oranges));// trueconsole.log(fruits.isSubsetOf(fruits));// trueconsole.log(emptySet.isSubsetOf(fruits));////// isSupersetOf////// trueconsole.log(fruits.isSupersetOf(applesAndBananas));// falseconsole.log(applesAndBananas.isSupersetOf(fruits));// falseconsole.log(applesAndBananas.isSupersetOf(oranges));// trueconsole.log(fruits.isSupersetOf(fruits));// falseconsole.log(emptySet.isSupersetOf(fruits));
我们要感谢 Kevin Gibbons,他不仅共同推动了 ECMAScript 中的这一特性,还提供了 TypeScript 中 Set、ReadonlySet 和 ReadonlySetLike 的声明!
独立声明 (Isolated Declarations)
本节由 Rob Palmer 共同编写,他支持了独立声明的设计。
声明文件(即 .d.ts 文件)向 TypeScript 描述了现有库和模块的形状。这种轻量级的描述包含了库的类型签名,排除了函数体等实现细节。发布这些文件是为了让 TypeScript 在不需要分析库本身的情况下就能高效地检查你对库的使用。虽然可以手工编写声明文件,但如果你在编写带类型的代码,让 TypeScript 使用 --declaration 从源文件自动生成它们会更安全、更简单。
TypeScript 编译器及其 API 一直负责生成声明文件;然而,有些用例是你可能想使用其他工具,或者传统的构建过程无法扩展的情况。
用例:更快的声明生成工具
想象一下,如果你想创建一个更快的工具来生成声明文件,也许是作为发布服务或新打包器的一部分。虽然生态系统中有很多将 TypeScript 转换为 JavaScript 的高速工具,但在将 TypeScript 转换为声明文件方面却并非如此。原因在于 TypeScript 的类型推断允许我们在不显式声明类型的情况下编写代码,这意味着声明生成可能非常复杂。
让我们考虑一个简单的例子,一个将两个导入变量相加的函数。
ts// util.tsexport let one = "1";export let two = "2";// add.tsimport { one, two } from "./util";export function add() { return one + two; }
即使我们唯一想做的事情就是生成 add.d.ts,TypeScript 也需要深入研究另一个导入文件 (util.ts),推断出 one 和 two 的类型是字符串,然后计算出对两个字符串使用 + 运算符会导致 string 返回类型。
ts// add.d.tsexport declare function add(): string;
虽然这种推断对于开发者体验很重要,但这意味着想要生成声明文件的工具将需要复制类型检查器的部分功能,包括推断能力以及解析模块说明符以跟踪导入的能力。
用例:并行声明生成与并行检查
想象一下,如果你有一个包含多个项目的仓库,且有一台多核 CPU,希望它能帮助你更快地检查代码。如果我们能通过在不同的核心上运行每个项目来同时检查所有这些项目,那该多好?
遗憾的是,我们没有自由并行地完成所有工作。原因在于我们必须按依赖顺序构建这些项目,因为每个项目都在根据其依赖项的声明文件进行检查。所以我们必须先构建依赖项以生成声明文件。TypeScript 的项目引用特性也以同样的方式工作,按“拓扑”依赖顺序构建项目集。
例如,如果我们有两个名为 backend 和 frontend 的项目,它们都依赖于一个名为 core 的项目,那么在 core 构建完成且其声明文件生成之前,TypeScript 无法开始检查 frontend 或 backend。

在上述图中,你可以看到存在一个瓶颈。虽然我们可以并行构建 frontend 和 backend,但在它们开始之前,必须先等待 core 完成构建。
我们该如何改进这一点?嗯,如果一个快速工具可以并行为 core 生成所有这些声明文件,TypeScript 就可以紧接着并行对 core、frontend 和 backend 进行类型检查。
解决方案:显式类型!
这两个用例的共同要求是我们需要一个跨文件的类型检查器来生成声明文件,这对工具社区来说要求很高。
作为一个更复杂的例子,如果我们想为以下代码生成声明文件……
tsimport { add } from "./add";const x = add();export function foo() {return x;}
……我们需要为 foo 生成签名。这需要查看 foo 的实现。foo 只是返回 x,所以获取 x 的类型需要查看 add 的实现。但这可能需要查看 add 的依赖项的实现,依此类推。我们在这里看到的是,生成声明文件需要大量的逻辑来计算出那些甚至可能不在当前文件中的不同地方的类型。
尽管如此,对于追求快速迭代时间和完全并行构建的开发者来说,还有另一种思考这个问题的方式。声明文件只需要模块公共 API 的类型——换句话说,导出的内容的类型。如果开发者愿意争议性地显式写出他们导出的内容的类型,工具就可以在不需要查看模块实现的情况下生成声明文件,并且不需要重新实现完整的类型检查器。
这就是新的 --isolatedDeclarations 选项的用武之地。--isolatedDeclarations 会在无法在没有类型检查器的情况下可靠地转换模块时报告错误。更简单地说,如果你的文件导出内容没有足够的注解,它会使 TypeScript 报告错误。
这意味着在上面的例子中,我们会看到类似以下的错误。
tsexport function foo() {// ~~~// error! Function must have an explicit// return type annotation with --isolatedDeclarations.return x;}
为什么错误是可取的?
因为它意味着 TypeScript 可以:
- 预先告诉我们其他工具在生成声明文件时是否会遇到问题。
- 提供快速修复来帮助添加这些缺失的注解。
不过,此模式并不需要到处都有注解。对于局部变量,可以忽略,因为它们不影响公共 API。例如,以下代码不会产生错误。
tsimport { add } from "./add";const x = add("1", "2"); // no error on 'x', it's not exported.export function foo(): string {return x;}
也有某些类型的计算是“琐碎”的。
ts// No error on 'x'.// It's trivial to calculate the type is 'number'export let x = 10;// No error on 'y'.// We can get the type from the return expression.export function y() {return 20;}// No error on 'z'.// The type assertion makes it clear what the type is.export function z() {return Math.max(x, y()) as number;}
使用 isolatedDeclarations
isolatedDeclarations 要求同时设置 declaration 或 composite 标志。
请注意,isolatedDeclarations 不会改变 TypeScript 执行生成的行为——只会改变报告错误的方式。重要的是,类似于 isolatedModules,在 TypeScript 中启用该特性并不会立即带来这里讨论的潜在好处。因此,请保持耐心并期待这一领域的未来发展。考虑到工具作者的需求,我们也应认识到,如今并不是所有 TypeScript 的声明生成都能被想要利用它的其他工具轻易复制。这是我们正在积极改进的地方。
此外,独立声明仍然是一个新特性,我们正在积极改善其体验。在 isolatedDeclarations 下,类和对象字面量中的某些场景(如计算属性声明)尚不支持。请密切关注这一领域,并随时提供反馈。
我们还认为值得指出的是,isolatedDeclarations 应该根据具体情况采用。使用 isolatedDeclarations 会失去一些开发者的人体工程学体验,因此如果你的环境没有利用前面提到的两个场景,它可能不是正确的选择。对于其他人来说,关于 isolatedDeclarations 的工作已经发现了许多优化和解锁不同并行构建策略的机会。在此期间,如果你愿意做出权衡,我们相信 isolatedDeclarations 可以成为加速构建过程的强大工具,因为外部工具正变得越来越广泛可用。
有关更多信息,请阅读 TypeScript 问题追踪器上的独立声明:特性状态讨论。
致谢
关于 isolatedDeclarations 的工作是 TypeScript 团队与 Bloomberg 和 Google 的基础设施与工具团队之间长期的协作成果。诸如来自 Google 的 Hana Joo(实现了独立声明错误的快速修复,稍后会详细介绍),以及 Ashley Claymore、Jan Kühle、Lisa Velden、Rob Palmer 和 Thomas Chetwin 等人在数月间参与了讨论、规范和实现。但我们认为特别值得一提的是来自 Bloomberg 的 Titian Cernicova-Dragomir 所提供的巨大贡献。Titian 在推动 isolatedDeclarations 的实现方面发挥了重要作用,并且在此之前多年一直是 TypeScript 项目的贡献者。
虽然该特性涉及许多更改,但你可以在此处查看独立声明的核心工作。
配置文件中的 ${configDir} 模板变量
在许多代码库中,重用一个作为其他配置文件“基础”的共享 tsconfig.json 文件是很常见的。这是通过在 tsconfig.json 文件中使用 extends 字段实现的。
json{"extends": "../../tsconfig.base.json","compilerOptions": {"outDir": "./dist"}}
这存在一个问题:tsconfig.json 文件中的所有路径都是相对于文件本身位置的。这意味着如果你有一个被多个项目使用的共享 tsconfig.base.json 文件,相对路径在衍生项目中往往不起作用。例如,想象以下 tsconfig.base.json:
json{"compilerOptions": {"typeRoots": ["./node_modules/@types","./custom-types"],"outDir": "dist"}}
如果作者的意图是每一个继承此文件的 tsconfig.json 都应该:
- 输出到相对于衍生
tsconfig.json的dist目录,以及 - 拥有一个相对于衍生
tsconfig.json的custom-types目录,
那么这将无法工作。typeRoots 路径将相对于共享 tsconfig.base.json 文件的位置,而不是继承它的项目。每个继承此共享文件的项目都需要声明自己的 outDir 和 typeRoots,且内容相同。这可能会令人沮丧且难以在项目之间保持同步,虽然上面的示例使用的是 typeRoots,但这对于 paths 和其他选项来说也是一个常见问题。
为了解决这个问题,TypeScript 5.5 引入了一个新的模板变量 ${configDir}。当 ${configDir} 被写入 tsconfig.json 或 jsconfig.json 文件的某些路径字段时,该变量会被替换为给定编译中配置文件的所在目录。这意味着上述 tsconfig.base.json 可以改写为:
json{"compilerOptions": {"typeRoots": ["${configDir}/node_modules/@types","${configDir}/custom-types"],"outDir": "${configDir}/dist"}}
现在,当项目继承此文件时,路径将相对于衍生 tsconfig.json,而不是共享的 tsconfig.base.json 文件。这使得在项目之间共享配置文件变得更加容易,并确保配置文件更具可移植性。
如果你打算使 tsconfig.json 文件可扩展,请考虑是否应该使用 ${configDir} 来代替 ./。
更多信息,请参阅提案问题和实现此功能的 Pull Request。
在生成声明文件时咨询 package.json 依赖项
以前,TypeScript 经常会抛出如下错误消息:
The inferred type of "X" cannot be named without a reference to "Y". This is likely not portable. A type annotation is necessary.
这通常是因为 TypeScript 的声明文件生成功能发现自己处于程序中从未显式导入的文件内容中。如果路径最终是相对的,生成对这样文件的导入可能是有风险的。不过,对于 package.json 的 dependencies(或 peerDependencies 和 optionalDependencies)中具有显式依赖项的代码库,在某些解析模式下生成此类导入应该是安全的。因此,在 TypeScript 5.5 中,在这种情况下我们会更加宽松,该错误的许多出现频率应该会消失。
参阅此 Pull Request 以了解有关此更改的更多详细信息。
编辑器和监视模式可靠性改进
TypeScript 添加了一些新功能或修复了现有逻辑,使 --watch 模式和 TypeScript 的编辑器集成感觉更可靠。希望这能转化为更少的 TSServer/编辑器重启。
正确刷新配置文件中的编辑器错误
TypeScript 可以为 tsconfig.json 文件生成错误;然而,这些错误实际上是在加载项目时生成的,编辑器通常不会直接请求 tsconfig.json 文件的这些错误。虽然这听起来像是一个技术细节,但这意味着当 tsconfig.json 中发布的所有错误都得到修复时,TypeScript 不会发布一组新的空错误,用户会被遗留的过时错误困扰,除非重新加载编辑器。
TypeScript 5.5 现在会有意发布一个事件来清除这些错误。在此处查看更多。
对删除后立即写入的更好处理
一些工具会选择删除文件然后从头开始创建新文件,而不是覆盖它们。例如在运行 npm ci 时就是这种情况。
虽然这对于这些工具来说是高效的,但对于 TypeScript 的编辑器场景来说可能会有问题,因为删除一个被监视的文件可能会销毁它及其所有传递依赖项。快速连续地删除和创建文件可能导致 TypeScript 拆除整个项目然后从头开始重建。
TypeScript 5.5 现在采用了一种更细致的方法,即保留被删除项目的部分内容,直到它接收到新的创建事件。这应该能使 npm ci 等操作在 TypeScript 下工作得更好。查看此处关于该方法的更多信息。
在失败的解析中跟踪符号链接
当 TypeScript 无法解析模块时,它仍然需要监视任何失败的查找路径,以防模块稍后被添加。以前对于符号链接目录没有这样做,这可能导致在类 monorepo 场景中出现可靠性问题,即在一个项目中发生的构建在另一个项目中未被发现。这个问题应该在 TypeScript 5.5 中得到修复,这意味着你不需要那么频繁地重启编辑器。
项目引用有助于自动导入
自动导入不再需要项目引用设置中的依赖项目至少有一个显式导入。相反,自动导入补全应该能够在你 tsconfig.json 的 references 字段中列出的任何内容中直接起作用。
性能和体积优化
语言服务和公共 API 中的单态化对象
在 TypeScript 5.0 中,我们确保了我们的 Node 和 Symbol 对象拥有一组具有一致初始化顺序的属性。这样做有助于减少不同操作中的多态性,从而允许运行时更快地获取属性。
通过此项更改,我们在编译器中见证了令人印象深刻的速度提升;然而,这些更改大多是在我们数据结构的内部分配器上执行的。语言服务以及 TypeScript 的公共 API 对某些对象使用了一组不同的分配器。这允许 TypeScript 编译器更加精简,因为仅用于语言服务的数据永远不会在编译器中使用。
在 TypeScript 5.5 中,语言服务和公共 API 也完成了相同的单态化工作。这意味着你的编辑器体验以及任何使用 TypeScript API 的构建工具都将获得显著的速度提升。事实上,在我们的基准测试中,我们看到在使用公共 TypeScript API 分配器时构建时间提升了 5-8%,语言服务操作速度提升了 10-20%。虽然这意味着内存有所增加,但我们认为这种权衡是值得的,并希望找到减少该内存开销的方法。现在的感觉应该会更加灵敏。
更多信息,参阅此处的更改。
单态化控制流节点
在 TypeScript 5.5 中,控制流图的节点已经过单态化,因此它们始终保持一致的形状。通过这样做,检查时间通常会减少约 1%。
控制流图优化
在许多情况下,控制流分析会遍历不提供任何新信息的节点。我们观察到,如果某些节点的前驱(或“支配者”)中不存在早期终止或影响,则这些节点始终可以跳过。因此,TypeScript 现在构建其控制流图时,通过链接到提供控制流分析所需信息的更早节点来利用这一点。这产生了一个更平坦的控制流图,遍历起来效率更高。此优化产生了适度的收益,在某些代码库中构建时间减少了高达 2%。
你可以在此处阅读更多信息。
跳过 transpileModule 和 transpileDeclaration 中的检查
TypeScript 的 transpileModule API 可用于将单个 TypeScript 文件的内容编译为 JavaScript。类似地,transpileDeclaration API(见下文)可用于为单个 TypeScript 文件生成声明文件。这些 API 的一个问题是,TypeScript 在内部会在输出之前对文件的全部内容执行完整的类型检查通过。这对于收集后续生成阶段所需的某些信息是必需的。
在 TypeScript 5.5 中,我们找到了一种避免进行完整检查的方法,仅在必要时懒加载地收集这些信息。transpileModule 和 transpileDeclaration 默认启用了此功能。因此,与这些 API 集成的工具(如带有 transpileOnly 的 ts-loader 和 ts-jest)应该会看到明显的速度提升。在我们的测试中,使用 transpileModule 时,我们通常会看到构建时间缩短约 2 倍。
TypeScript 包大小缩减
进一步利用我们在 5.0 中向模块的过渡,我们通过让 tsserver.js 和 typingsInstaller.js 从公共 API 库导入,而不是让它们各自生成独立捆绑包,显著减小了 TypeScript 的整体包大小。
这使 TypeScript 在磁盘上的大小从 30.2 MB 减小到 20.4 MB,并将其打包大小从 5.5 MB 减小到 3.7 MB!
声明生成中的节点重用
作为启用 isolatedDeclarations 工作的一部分,我们大幅提升了 TypeScript 在生成声明文件时直接复制你输入源代码的频率。
例如,假设你编写了:
tsexport const strBool: string | boolean = "hello";export const boolStr: boolean | string = "world";
请注意,联合类型是等价的,但联合的顺序不同。在生成声明文件时,TypeScript 有两种等价的输出可能性。
第一种是对每个类型使用一致的规范表示:
tsexport const strBool: string | boolean;export const boolStr: string | boolean;
第二种是按原样重用类型注解:
tsexport const strBool: string | boolean;export const boolStr: boolean | string;
由于以下几个原因,第二种方法通常更可取:
- 许多等价表示仍然编码了一定程度的意图,在声明文件中最好保留这些意图。
- 生成类型的新表示可能有些昂贵,所以最好避免。
- 用户编写的类型通常比生成的类型表示更短。
在 5.5 中,我们极大地改善了 TypeScript 可以正确识别并安全、正确地按输入文件中编写原样打印类型的地方。其中许多是隐形的性能改进——以前 TypeScript 会生成新的语法节点集并将它们序列化为字符串。现在,TypeScript 可以直接在原始语法节点上操作,这更廉价且更快速。
缓存来自判别联合的上下文类型
当 TypeScript 为对象字面量等表达式请求上下文类型时,它经常会遇到联合类型。在这些情况下,TypeScript 会尝试根据具有已知值(即判别属性)的属性来过滤联合成员。这项工作可能相当昂贵,特别是如果你最终得到一个由许多属性组成的对象时。在 TypeScript 5.5 中,大部分计算都被缓存了一次,因此 TypeScript 不需要为对象字面量中的每个属性重新计算它。执行此优化为编译 TypeScript 编译器本身节省了 250ms。
更轻松地从 ECMAScript 模块使用 API
以前,如果你在 Node.js 中编写 ECMAScript 模块,则无法从 typescript 包中使用命名导入。
tsimport { createSourceFile } from "typescript"; // ❌ errorimport * as ts from "typescript";ts.createSourceFile // ❌ undefined???ts.default.createSourceFile // ✅ works - but ugh!
这是因为 cjs-module-lexer 没有识别 TypeScript 生成的 CommonJS 代码模式。这个问题已得到修复,用户现在可以在 Node.js 的 ECMAScript 模块中使用来自 TypeScript npm 包的命名导入。
tsimport { createSourceFile } from "typescript"; // ✅ works now!import * as ts from "typescript";ts.createSourceFile // ✅ works now!
有关更多信息,参阅此处的更改。
transpileDeclaration API
TypeScript 的 API 公开了一个名为 transpileModule 的函数。它旨在让你轻松编译单个 TypeScript 代码文件。因为它无法访问整个程序,所以需要注意的是,如果代码违反了 isolatedModules 选项下的任何错误,它可能不会产生正确的输出。
在 TypeScript 5.5 中,我们添加了一个类似的新 API,称为 transpileDeclaration。此 API 与 transpileModule 类似,但它是专门设计用于基于某些输入源文本生成单个声明文件的。就像 transpileModule 一样,它无法访问完整的程序,并且适用类似的警告:只有当输入代码在新的 isolatedDeclarations 选项下没有错误时,它才会生成准确的声明文件。
如果需要,此函数可用于在 isolatedDeclarations 模式下跨所有文件并行化声明生成。
有关更多信息,参阅此处的实现。
显著的行为更改
本节重点介绍了一组值得注意的更改,在进行任何升级时都应予以确认和理解。有时它会突出显示弃用、移除和新的限制。它也可能包含功能上有所改进但通过引入新错误而可能影响现有构建的错误修复。
禁用在 TypeScript 5.0 中弃用的特性
TypeScript 5.0 弃用了以下选项和行为:
charsettarget: ES3importsNotUsedAsValuesnoImplicitUseStrictnoStrictGenericCheckskeyofStringsOnlysuppressExcessPropertyErrorssuppressImplicitAnyIndexErrorsoutpreserveValueImports- 项目引用中的
prepend - 隐式特定于 OS 的
newLine
为了继续使用上述已弃用的选项,使用 TypeScript 5.0 及更新版本的开发者必须指定一个名为 ignoreDeprecations 且值为 "5.0" 的新选项。
在 TypeScript 5.5 中,这些选项不再有任何效果。为了帮助平滑升级,你仍然可以在 tsconfig 中指定它们,但在 TypeScript 6.0 中,指定它们将导致错误。另请参阅概述我们弃用策略的标志弃用计划。
有关这些弃用计划的更多信息可在 GitHub 上获得,其中包含了如何最好地适应你的代码库的建议。
lib.d.ts 变更
为 DOM 生成的类型可能会对你的代码库类型检查产生影响。有关更多信息,参阅 TypeScript 5.5 的 DOM 更新。
更严格的装饰器解析
自 TypeScript 最初引入对装饰器的支持以来,该提案的指定语法已得到加强。TypeScript 现在对其允许的形式更加严格。虽然很少见,但现有的装饰器可能需要加括号以避免错误。
tsclass DecoratorProvider {decorate(...args: any[]) { }}class D extends DecoratorProvider {m() {class C {@super.decorate // ❌ errormethod1() { }@(super.decorate) // ✅ okaymethod2() { }}}}
参阅此处有关更改的更多信息。
undefined 不再是可定义的类型名称
TypeScript 一直不允许与内置类型冲突的类型别名名称。
ts// Illegaltype null = any;// Illegaltype number = any;// Illegaltype object = any;// Illegaltype any = any;
由于一个错误,此逻辑并未应用于内置类型 undefined。在 5.5 中,这现在被正确标识为错误。
ts// Now also illegaltype undefined = any;
名为 undefined 的类型别名的裸引用实际上从未真正工作过。你可以定义它们,但不能将它们用作非限定类型名称。
tsexport type undefined = string;export const m: undefined = "";// ^// Errors in 5.4 and earlier - the local definition of 'undefined' was not even consulted.
更多信息,参阅此处的更改。
简化参考指令声明生成
在生成声明文件时,TypeScript 会在认为需要时合成参考指令。例如,所有 Node.js 模块都是环境声明的,因此不能仅由模块解析加载。像这样的文件:
tsximport path from "path";export const myPath = path.parse(__filename);
将生成如下声明文件:
tsx/// <reference types="node" />import path from "path";export declare const myPath: path.ParsedPath;
即使参考指令从未出现在原始源文件中。
同样,TypeScript 也删除了它认为不需要作为输出一部分的参考指令。例如,假设我们有一个指向 jest 的参考指令;然而,假设生成声明文件并不需要该参考指令。TypeScript 会直接删除它。因此在以下示例中:
tsx/// <reference types="jest" />import path from "path";export const myPath = path.parse(__filename);
TypeScript 仍然会生成:
tsx/// <reference types="node" />import path from "path";export declare const myPath: path.ParsedPath;
在进行 isolatedDeclarations 工作时,我们意识到这种逻辑对于任何试图在没有类型检查或仅使用单个文件上下文的情况下实现声明生成器的人来说是不可持续的。从用户的角度来看,这种行为也很难理解;除非你确切了解类型检查期间发生了什么,否则参考指令是否出现在生成的文件中似乎不一致且难以预测。为了防止在启用 isolatedDeclarations 时声明生成有所不同,我们知道我们的生成需要改变。
通过实验,我们发现几乎所有 TypeScript 合成参考指令的情况都只是为了引入 node 或 react。在这些情况下,预期是下游用户已经通过 tsconfig.json "types" 或库导入引用了这些类型,因此不再合成这些参考指令不太可能破坏任何人的代码。值得注意的是,对于 lib.d.ts 来说已经如此;当模块导出 WeakMap 时,TypeScript 不会合成对 lib="es2015" 的引用,而是假设下游用户已将其包含在其环境中。
对于由库作者编写(而非合成)的参考指令,进一步实验表明几乎所有都被移除了,从未出现在输出中。大多数被保留的参考指令都是损坏的,且可能无意被保留。
鉴于这些结果,我们决定在 TypeScript 5.5 中极大简化声明生成中的参考指令。更一致的策略将帮助库作者和消费者更好地控制他们的声明文件。
参考指令不再被合成。除非标记有新的 preserve="true" 属性,否则用户编写的参考指令不再被保留。具体来说,像这样的输入文件:
tsx/// <reference types="some-lib" preserve="true" />/// <reference types="jest" />import path from "path";export const myPath = path.parse(__filename);
将生成:
tsx/// <reference types="some-lib" preserve="true" />import path from "path";export declare const myPath: path.ParsedPath;
添加 preserve="true" 向后兼容旧版本的 TypeScript,因为未知属性会被忽略。
这一更改也提高了性能;在我们的基准测试中,启用了声明生成的项目的生成阶段效率提升了 1-4%。