推断类型谓词
本节由 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。
如果你期望推断出类型谓词,但它并没有出现,那么你可能违反了第二条规则。这常常出现在“真值性”检查中:
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
更多信息请查看实现此功能的拉取请求以及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。更多信息请参见此处的实现拉取请求。
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 的检查现在还能感知某些 RegExp 特性的使用是否比你所面向的 ECMAScript 版本更新。例如,如果我们在 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 的声明!
孤立声明
本节由 Rob Palmer 共同撰写,他支持了孤立声明的设计。
声明文件(又称 .d.ts 文件)向 TypeScript 描述现有库和模块的形状。这种轻量描述包含库的类型签名,不包含函数体等实现细节。它们被发布出来,使 TypeScript 能够高效地检查你对某个库的使用,而无需分析库本身。虽然可以手写声明文件,但如果你在编写有类型的代码,使用 --declaration 让 TypeScript 从源文件自动生成它们会安全、简单得多。
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;
虽然这种推断对开发者体验很重要,但这也意味着想要生成声明文件的工具需要复制类型检查器的部分功能,包括推断以及解析模块说明符以跟进导入的能力。
用例:并行输出声明与并行检查
设想你有一个包含许多项目的 monorepo,以及一块多核 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 issue tracker 上关于 孤立声明:功能现状 的讨论。
致谢
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}。当在 tsconfig.json 或 jsconfig.json 文件的某些路径字段中写入 ${configDir} 时,该变量会在给定的编译中替换为配置文件所在的目录。这意味着上面的 tsconfig.base.json 可以改写为:
json{"compilerOptions": {"typeRoots": ["${configDir}/node_modules/@types","${configDir}/custom-types"],"outDir": "${configDir}/dist"}}
现在,当项目扩展此文件时,路径会相对于派生的 tsconfig.json,而不是共享的 tsconfig.base.json。这使得跨项目共享配置文件更加容易,并确保配置文件具有更好的可移植性。
如果你打算让某个 tsconfig.json 文件可被扩展,请考虑是否应将 ./ 改写为 ${configDir}。
如需了解更多信息,请参阅提议 issue和实现该功能的拉取请求。
生成声明文件时参考 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 中,遇到这种情况时我们会更加宽松,这一错误的许多出现都会消失。
有关这一改动的更多细节,请参阅此拉取请求。
编辑器和监视模式的可靠性改进
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 中向模块的过渡,我们显著减小了 TypeScript 的整体包体积:让 tsserver.js 和 typingsInstaller.js 从公共 API 库导入,而不是让它们各自生成独立的打包文件。
这将 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 - 隐式依赖操作系统的
newLine
为了继续使用上述已弃用的选项,使用 TypeScript 5.0 及其他较新版本的开发者必须指定一个名为 ignoreDeprecations 的新选项,其值为 "5.0"。
在 TypeScript 5.5 中,这些选项不再有任何效果。为了帮助平稳升级,你仍然可以在 tsconfig 中指定它们,但在 TypeScript 6.0 中指定它们将会报错。另请参见 Flag Deprecation Plan,其中概述了我们的弃用策略。
关于这些弃用计划的更多信息可在 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% 的改进。