Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

TypeScript 5.5

推断的类型谓词

本节由 Dan Vanderkam 撰写,他在 TypeScript 5.5 中实现了此功能。感谢 Dan!

TypeScript 的控制流分析在跟踪变量类型随着代码执行而变化方面表现出色:

tsx
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 版本中都是错误:

tsx
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,类型检查器接受这段代码:

tsx
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 函数推断了一个类型谓词。你可以通过将其提取到一个独立函数来更清楚地看到发生了什么:

tsx
// function 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 将推断函数返回类型谓词:

  1. 函数没有显式返回类型或类型谓词注解。
  2. 函数只有一个 return 语句,且没有隐式返回。
  3. 函数不改变其参数。
  4. 函数返回一个 boolean 表达式,该表达式与对参数的细化相关联。

通常,它的工作方式如你所愿。以下是几个推断类型谓词的更多示例:

tsx
// 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 numberx is NonNullable<T>)的签名。

类型谓词具有“当且仅当”的语义。如果一个函数返回 x is T,那么它意味着:

  1. 如果函数返回 true,则 x 的类型为 T
  2. 如果函数返回 false,则 x 的类型T

如果你期望推断出类型谓词但没有,那么你可能违反了第二条规则。这通常出现在“真值性”检查中:

tsx
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,则 scorenumber。但如果返回 false,则 score 可能是 undefinednumber(具体来说,是 0)。这是一个真正的 bug:如果任何学生考了零分,过滤掉他们的分数会使平均值偏高。高于平均值的人会更少,而伤心的人会更多!

与第一个示例一样,最好显式过滤掉 undefined 值:

tsx
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;  // 没问题!
}

对于对象类型(没有歧义),真值性检查推断类型谓词。请记住,函数必须返回 boolean 才能成为推断类型谓词的候选:x => !!x 可能会推断类型谓词,但 x => x 肯定不会。

显式类型谓词继续像以前一样工作。TypeScript 不会检查它是否会推断出相同的类型谓词。显式类型谓词("is")并不比类型断言("as")更安全。

如果 TypeScript 现在推断出的类型比你想要的更精确,此功能可能会破坏现有代码。例如:

tsx
// 以前: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 你想要的类型:

tsx
const nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null);
nums.push(null);  // 所有版本都可以

有关更多信息,请查看实现拉取请求Dan 关于实现此功能的博客文章

对常量索引访问的控制流收窄

objkey 实际上都是常量时,TypeScript 现在能够收窄 obj[key] 形式的表达式。

ts
function f1(obj: Record<string, unknown>, key: string) {
    if (typeof obj[key] === "string") {
        // 现在可以,以前是错误
        obj[key].toUpperCase();
    }
}

在上面,objkey 都没有被改变,因此 TypeScript 可以在 typeof 检查后将 obj[key] 的类型收窄为 string。有关更多信息,请在此处查看实现拉取请求

JSDoc @import 标签

如今,如果你想在 JavaScript 文件中仅导入用于类型检查的内容,这很麻烦。JavaScript 开发人员不能简单地在运行时不存在的情况下导入名为 SomeType 的类型。

js
// ./some-module.d.ts
export interface SomeType {
    // ...
}

// ./index.js
import { SomeType } from "./some-module"; // ❌ 运行时错误!

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
    // ...
}

SomeType 在运行时不存在,因此导入将失败。开发人员可以改用命名空间导入。

js
import * 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 现在对正则表达式进行基本的语法检查!

ts
let myRegex = /@robot(\s+(please|immediately)))? do some task/;
//                                            ~
// 错误!
// 意外的 ')'. 你是想用反斜杠转义它吗?

这是一个简单的例子,但这种检查可以捕获许多常见错误。事实上,TypeScript 的检查略超出了语法检查。例如,TypeScript 现在可以捕获关于不存在的反向引用的问题。

ts
let myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u;
//                                                        ~
// 错误!
// 此反向引用引用了一个不存在的组。
// 此正则表达式中只有 2 个捕获组。

这同样适用于命名捕获组。

ts
let myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/;
//                                                                                        ~~~~~~~~~~~
// 错误!
// 此正则表达式中没有名为 'namedImport' 的捕获组。

TypeScript 的检查现在还知道某些 RegExp 特性在目标 ECMAScript 版本较新时使用的情况。例如,如果我们在 ES5 目标中使用上面的命名捕获组,我们将得到一个错误。

ts
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 类型提出的新方法

其中一些方法,如 unionintersectiondifferencesymmetricDifference,接受另一个 Set 并返回一个新的 Set 作为结果。其他方法,isSubsetOfisSupersetOfisDisjointFrom,接受另一个 Set 并返回一个 boolean。这些方法都不会改变原始的 Set

以下是你如何使用这些方法及其行为的快速示例:

ts
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 中的 SetReadonlySetReadonlySetLike 提供了声明

隔离声明

本节由 Rob Palmer 合著,他支持了隔离声明的设计。

声明文件(又称 .d.ts 文件)向 TypeScript 描述现有库和模块的形状。这种轻量级描述包括库的类型签名,并排除了实现细节(如函数体)。它们被发布,以便 TypeScript 可以高效地检查你对库的使用,而无需分析库本身。虽然可以手写声明文件,但如果你在编写类型化代码,让 TypeScript 使用 --declaration 从源文件自动生成声明文件要安全、简单得多。

TypeScript 编译器及其 API 一直承担着生成声明文件的工作;然而,在某些用例中,你可能希望使用其他工具,或者传统的构建过程无法扩展。

用例:更快的声明输出工具

想象一下,你想要创建一个更快的工具来生成声明文件,也许作为发布服务或新打包器的一部分。虽然有一个蓬勃发展的快速工具生态系统可以将 TypeScript 转换为 JavaScript,但对于将 TypeScript 转换为声明文件来说,情况并非如此。原因是 TypeScript 的推断允许我们在不显式声明类型的情况下编写代码,这意味着声明输出可能很复杂。

让我们看一个简单的示例,一个将两个导入变量相加的函数。

ts
// 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),推断 onetwo 的类型是字符串,然后计算出两个字符串上的 + 运算符将导致 string 返回类型。

ts
// add.d.ts
export declare function add(): string;

虽然这种推断对开发者体验很重要,但这意味着想要生成声明文件的工具需要复制类型检查器的部分功能,包括推断和解析模块说明符以跟踪导入的能力。

用例:并行声明输出和并行检查

想象一下,如果你有一个包含许多项目的 monorepo 和一个多核 CPU,它只希望能帮助你更快地检查代码。如果我们可以在不同的核心上运行每个项目,从而同时检查所有这些项目,那不是很好吗?

不幸的是,我们没有自由并行进行所有工作。原因是我们必须按依赖顺序构建这些项目,因为每个项目都在检查其依赖项的声明文件。因此,我们必须首先构建依赖项以生成声明文件。TypeScript 的项目引用功能以相同的方式工作,按“拓扑”依赖顺序构建项目集。

例如,如果我们有两个名为 backendfrontend 的项目,它们都依赖于一个名为 core 的项目,那么 TypeScript 无法开始对 frontendbackend 进行类型检查,直到 core 被构建并且其声明文件生成完毕。

frontend 和 backend 指向 core,其他东西可能指向每一个

在上图中,你可以看到我们有一个瓶颈。虽然我们可以并行构建 frontendbackend,但我们需要先等待 core 完成构建,然后两者才能开始。

我们如何改进这一点?嗯,如果一个快速工具可以并行core 生成所有这些声明文件,那么 TypeScript 随后可以立即对 corefrontendbackend 进行类型检查,同样并行进行。

解决方案:显式类型!

两个用例中的共同要求是我们需要一个跨文件类型检查器来生成声明文件。这对工具社区来说要求太高了。

作为一个更复杂的例子,如果我们想要为以下代码生成声明文件...

ts
import { add } from "./add";

const x = add();

export function foo() {
    return x;
}

...我们需要为 foo 生成签名。这需要查看 foo 的实现。foo 只返回 x,因此获取 x 的类型需要查看 add 的实现。但这可能需要查看 add 的依赖项的实现,依此类推。我们在这里看到的是,生成声明文件需要大量逻辑来确定可能甚至不在当前文件本地的不同位置的类型。

尽管如此,对于寻求快速迭代时间和完全并行构建的开发人员来说,还有另一种思考问题的方式。声明文件只需要模块公共 API 的类型——换句话说,就是导出的东西的类型。如果(有争议地)开发人员愿意显式写出他们导出的东西的类型,那么工具就可以在不查看模块实现的情况下生成声明文件——并且无需重新实现完整的类型检查器。

这就是新的 --isolatedDeclarations 选项的作用所在。当模块在没有类型检查器的情况下无法可靠转换时,--isolatedDeclarations 会报告错误。更直白地说,如果你有一个文件在其导出上没有足够注解,它会使 TypeScript 报告错误。

这意味着在上面的例子中,我们会看到如下错误:

ts
export function foo() {
//              ~~~
// 错误!使用 --isolatedDeclarations 时,函数必须具有显式返回类型注解。
    return x;
}

为什么错误是可取的?

因为它意味着 TypeScript 可以

  1. 提前告知我们其他工具在生成声明文件时是否会遇到问题
  2. 提供快速修复以帮助添加这些缺失的注解。

不过,此模式并不需要处处都有注解。对于局部变量,可以忽略,因为它们不影响公共 API。例如,以下代码不会产生错误:

ts
import { add } from "./add";

const x = add("1", "2"); // 对 'x' 没有错误,它没有被导出。

export function foo(): string {
    return x;
}

还有一些表达式,其类型是“平凡”可计算的。

ts
// 对 'x' 没有错误。
// 计算类型为 'number' 是平凡的
export let x = 10;

// 对 'y' 没有错误。
// 我们可以从返回表达式获取类型。
export function y() {
    return 20;
}

// 对 'z' 没有错误。
// 类型断言明确了类型。
export function z() {
    return Math.max(x, y()) as number;
}

使用 isolatedDeclarations

isolatedDeclarations 要求同时设置 declarationcomposite 标志。

请注意,isolatedDeclarations 不会改变 TypeScript 执行输出的方式——只是改变它报告错误的方式。重要的是,类似于 isolatedModules,在 TypeScript 中启用该功能不会立即带来此处讨论的潜在好处。所以请耐心等待,并期待该领域的未来发展。考虑到工具作者,我们也应该认识到,如今并非所有 TypeScript 的声明输出都能被其他想要将其用作指南的工具轻易复制。这是我们正在积极努力改进的事情。

除此之外,隔离声明仍然是一项新功能,我们正在积极改进体验。某些场景,例如在类和对象字面量中使用计算属性声明,在 isolatedDeclarations 下尚支持。请关注这一领域,并随时向我们提供反馈。

我们还认为值得指出的是,isolatedDeclarations 应视具体情况采用。使用 isolatedDeclarations 会失去一些开发者体验,因此如果您的设置没有利用前面提到的两种场景,这可能不是正确的选择。对于其他人,isolatedDeclarations 的工作已经揭示了许多优化和机会,以解锁不同的并行构建策略。同时,如果你愿意做出权衡,我们相信随着外部工具变得更广泛可用,isolatedDeclarations 可以成为加速构建过程的有力工具。

有关更多信息,请阅读 TypeScript 问题跟踪器上的隔离声明:功能状态讨论。

致谢

isolatedDeclarations 的工作是 TypeScript 团队与彭博社和谷歌的基础设施和工具团队长期合作的结果。来自谷歌的 Hana Joo 实现了隔离声明错误的快速修复(稍后会详细介绍),以及 Ashley Claymore、Jan Kühle、Lisa Velden、Rob Palmer 和 Thomas Chetwin 参与了数月的讨论、规范和实现。但我们认为特别值得指出的是 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

json5
{
    "compilerOptions": {
        "typeRoots": [
            "./node_modules/@types",
            "./custom-types"
        ],
        "outDir": "dist"
    }
}

如果作者的意图是每个扩展此文件的 tsconfig.json 应该

  1. 输出到相对于派生 tsconfig.jsondist 目录,并且
  2. 拥有一个相对于派生 tsconfig.jsoncustom-types 目录,

那么这将不起作用。typeRoots 路径将相对于共享的 tsconfig.base.json 文件的位置,而不是扩展它的项目。每个扩展此共享文件的项目都需要声明自己的 outDirtypeRoots,内容相同。这可能会令人沮丧且难以在项目间保持同步,虽然上面的示例使用了 typeRoots,但这是 paths 和其他选项的常见问题。

为了解决这个问题,TypeScript 5.5 引入了一个新的模板变量 ${configDir}。当在 tsconfig.jsonjsconfig.json 文件的某些路径字段中写入 ${configDir} 时,该变量将在给定编译中被替换为配置文件所在的目录。这意味着上面的 tsconfig.base.json 可以重写为:

json5
{
    "compilerOptions": {
        "typeRoots": [
            "${configDir}/node_modules/@types",
            "${configDir}/custom-types"
        ],
        "outDir": "${configDir}/dist"
    }
}

现在,当一个项目扩展此文件时,路径将相对于派生的 tsconfig.json,而不是共享的 tsconfig.base.json 文件。这使得跨项目共享配置文件更加容易,并确保配置文件更具可移植性。

如果你打算使 tsconfig.json 文件可扩展,请考虑是否应将 ./ 改为用 ${configDir} 编写。

有关更多信息,请参阅提案问题实现拉取请求

在生成声明文件时查阅 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.jsondependencies(或 peerDependenciesoptionalDependencies)中具有显式依赖项的代码库,在某些解析模式下生成这样的导入应该是安全的。所以在 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.jsonreferences 字段中列出的任何内容上工作。

在此处查看更多关于实现拉取请求的信息

性能和大小优化

语言服务和公共 API 中的单态化对象

在 TypeScript 5.0 中,我们确保我们的 NodeSymbol 对象具有一致的属性集和一致的初始化顺序。这样做有助于减少不同操作中的多态性,从而使运行时能够更快地获取属性。

通过进行此更改,我们在编译器中看到了令人印象深刻的加速;然而,这些更改中的大多数是在我们数据结构的内部分配器上执行的。语言服务以及 TypeScript 的公共 API 对某些对象使用不同的分配器集。这使 TypeScript 编译器更加精简,因为仅用于语言服务的数据永远不会在编译器中使用。

在 TypeScript 5.5 中,相同的单态化工作已经为语言服务和公共 API 完成。这意味着你的编辑器体验以及任何使用 TypeScript API 的构建工具都将显著加快。事实上,在我们的基准测试中,我们发现在使用公共 TypeScript API 的分配器时,构建时间加快了 5-8%,而语言服务操作加快了 10-20%。虽然这确实意味着内存增加,但我们认为这种权衡是值得的,并希望找到减少内存开销的方法。现在事情应该感觉灵敏多了。

有关更多信息,请在此处查看更改

单态化的控制流节点

在 TypeScript 5.5 中,控制流图的节点已被单态化,以便它们始终具有一致的形状。通过这样做,检查时间通常会减少约 1%。

在此处查看此更改

控制流图的优化

在许多情况下,控制流分析会遍历不提供任何新信息的节点。我们观察到,在没有提前终止或某些节点的前驱(或“支配节点”)没有影响的情况下,意味着这些节点总是可以跳过。因此,TypeScript 现在构建其控制流图以利用这一点,链接到为控制流分析确实提供有趣信息的较早节点。这产生了更扁平的控制流图,遍历起来更高效。此优化带来了适度的收益,但在某些代码库上构建时间减少了高达 2%。

你可以在此处阅读更多信息

transpileModuletranspileDeclaration 中跳过检查

TypeScript 的 transpileModule API 可用于将单个 TypeScript 文件的内容编译为 JavaScript。类似地,transpileDeclaration API(见下文)可用于为单个 TypeScript 文件生成声明文件。这些 API 的问题之一是 TypeScript 内部会在输出前对文件的全部内容执行完整的类型检查。这是必要的,以便收集稍后将在输出阶段使用的某些信息。

在 TypeScript 5.5 中,我们找到了一种避免执行完整检查的方法,仅在需要时懒惰地收集此信息,并且 transpileModuletranspileDeclaration 都默认启用此功能。因此,与这些 API 集成的工具,如使用 transpileOnlyts-loaderts-jest,应该会看到明显的加速。在我们的测试中,我们普遍观察到使用 transpileModule 时构建时间大约快了 2 倍

TypeScript 包大小减小

进一步利用我们在 5.0 中向模块的过渡通过使 tsserver.jstypingsInstaller.js 从公共 API 库导入,而不是让每个都生成独立的捆绑包,我们显著减少了 TypeScript 的整体包大小。

这使 TypeScript 的磁盘大小从 30.2 MB 减少到 20.4 MB,并将其打包大小从 5.5 MB 减少到 3.7 MB!

声明输出中的节点重用

作为启用 isolatedDeclarations 工作的一部分,我们大大提高了 TypeScript 在生成声明文件时直接复制输入源代码的频率。

例如,假设你写了

ts
export const strBool: string | boolean = "hello";
export const boolStr: boolean | string = "world";

注意联合类型是等价的,但联合的顺序不同。当输出声明文件时,TypeScript 有两种等价的输出可能性。

第一种是为每种类型使用一致的规范表示:

ts
export const strBool: string | boolean;
export const boolStr: string | boolean;

第二种是精确重用编写的类型注解:

ts
export const strBool: string | boolean;
export const boolStr: boolean | string;

第二种方法通常是优选的,原因如下:

  • 许多等价的表示仍然编码了一定程度的意图,最好在声明文件中保留
  • 生成类型的新表示可能有些昂贵,因此最好避免
  • 用户编写的类型通常比生成的类型表示更短

在 5.5 中,我们大大增加了 TypeScript 可以正确识别安全且正确地将类型按输入文件中的原样打印回的地方的数量。这些案例中有许多是看不见的性能改进——TypeScript 会生成一组新的语法节点并将它们序列化为字符串。相反,TypeScript 现在可以直接在原始语法节点上操作,这更便宜、更快。

缓存来自可辨识联合的上下文类型

当 TypeScript 询问像对象字面量这样的表达式的上下文类型时,它经常会遇到联合类型。在这些情况下,TypeScript 尝试基于具有已知值的已知属性(即可辨识属性)过滤联合的成员。这项工作可能相当昂贵,尤其是当你最终得到一个包含许多许多属性的对象时。在 TypeScript 5.5 中,大部分计算被缓存一次,这样 TypeScript 就不需要为对象字面量中的每个属性重新计算它。执行此优化将编译 TypeScript 编译器本身的时间缩短了 250 毫秒。

从 ECMAScript 模块更轻松地使用 API

以前,如果你在 Node.js 中编写 ECMAScript 模块,则无法从 typescript 包使用命名导入。

ts
import { createSourceFile } from "typescript"; // ❌ 错误

import * as ts from "typescript";
ts.createSourceFile // ❌ undefined???

ts.default.createSourceFile // ✅ 有效 - 但呃!

这是因为 cjs-module-lexer 无法识别 TypeScript 生成的 CommonJS 代码的模式。这已修复,用户现在可以在 Node.js 中使用带有 ECMAScript 模块的 TypeScript npm 包进行命名导入。

ts
import { createSourceFile } from "typescript"; // ✅ 现在有效!

import * as ts from "typescript";
ts.createSourceFile // ✅ 现在有效!

有关更多信息,请在此处查看更改

transpileDeclaration API

TypeScript 的 API 公开了一个名为 transpileModule 的函数。它旨在使编译单个 TypeScript 代码文件变得容易。由于它无法访问整个程序,因此需要注意如果代码在 isolatedModules 选项下违反任何错误,它可能无法产生正确的输出。

在 TypeScript 5.5 中,我们添加了一个名为 transpileDeclaration 的新类似 API。此 API 类似于 transpileModule,但它专门设计用于基于某些输入源文本生成单个声明文件。就像 transpileModule 一样,它无法访问完整的程序,并且有类似的注意事项:只有在输入代码在 isolatedDeclarations 选项下没有错误时,它才能生成准确的声明文件。

如果需要,此功能可用于在 isolatedDeclarations 模式下并行化所有文件的声明输出。

有关更多信息,请在此处查看实现

值得注意的行为变化

本节重点介绍一组在升级时应该注意和理解的重要更改。 有时它会突出显示弃用、删除和新限制。 它还可能包含功能上改进的错误修复,但也可能通过引入新错误来影响现有构建。

禁用 TypeScript 5.0 中弃用的功能

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

  • charset
  • target: ES3
  • importsNotUsedAsValues
  • noImplicitUseStrict
  • noStrictGenericChecks
  • keyofStringsOnly
  • suppressExcessPropertyErrors
  • suppressImplicitAnyIndexErrors
  • out
  • preserveValueImports
  • 项目引用中的 prepend
  • 隐式操作系统特定的 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 现在对其允许的形式更加严格。虽然罕见,但现有的装饰器可能需要加上括号以避免错误。

ts
class DecoratorProvider {
    decorate(...args: any[]) { }
}

class D extends DecoratorProvider {
    m() {
        class C {
            @super.decorate // ❌ 错误
            method1() { }

            @(super.decorate) // ✅ 可以
            method2() { }
        }
    }
}

在此处查看更多关于更改的信息

undefined 不再是可以定义的类型名称

TypeScript 一直禁止与内置类型冲突的类型别名名称:

ts
// 非法
type null = any;
// 非法
type number = any;
// 非法
type object = any;
// 非法
type any = any;

由于一个 bug,此逻辑不适用于内置类型 undefined。在 5.5 中,这现在被正确识别为错误:

ts
// 现在也是非法的
type undefined = any;

对名为 undefined 的类型别名的裸引用一开始从未真正起作用。你可以定义它们,但不能将它们用作非限定类型名称。

ts
export type undefined = string;
export const m: undefined = "";
//           ^
// 在 5.4 及更早版本中出错 - 甚至没有考虑 'undefined' 的本地定义。

有关更多信息,请在此处查看更改

简化的引用指令声明输出

当生成声明文件时,TypeScript 会在认为需要时合成一个引用指令。例如,所有 Node.js 模块都是环境声明的,因此不能仅通过模块解析加载。像这样的文件:

tsx
import 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 合成引用指令的几乎所有情况都是为了引入 nodereact。这些情况下,下游用户已经通过 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% 的改进。