TypeScript 5.7 正式发布


今天,我们很高兴宣布 TypeScript 5.7 的正式发布!

如果你还不熟悉 TypeScript,它是一种基于 JavaScript 的语言,通过添加类型声明和注解的语法来增强功能。这些语法可以被 TypeScript 编译器用来对代码进行类型检查,并在编译时移除这些类型信息,从而生成简洁、惯用的 JavaScript 代码。类型检查非常有用,因为它可以提前捕获代码中的错误;此外,为代码添加类型还能提升可读性,并让代码编辑器提供强大的功能,比如自动补全、重构、查找引用等。

TypeScript 是 JavaScript 的超集,因此任何有效的 JavaScript 代码也是有效的 TypeScript 代码。事实上,如果你在 Visual Studio 或 VS Code 等编辑器中编写 JavaScript,TypeScript 也在背后为你的 JavaScript 编辑体验提供支持!你可以在 TypeScript 官网 上了解更多信息。

要开始使用 TypeScript,可以通过 npm 运行以下命令:

npm install -D typescript

接下来,让我们看看 TypeScript 5.7 的新功能!


检查从未初始化的变量

长久以来,TypeScript 能够检测出变量在所有前置分支中未被初始化的问题:

let result: number;
if (someCondition()) {
    result = doSomeWork();
} else {
    let temporaryWork = doSomeWork();
    temporaryWork *= 2;
    // 忘记将值赋给 'result'
}

console.log(result); // 错误:变量 'result' 在使用前未赋值。

然而,有些情况分析却无法生效。例如,当变量在单独的函数中被访问时,类型系统无法确定函数何时被调用,因此会“乐观”地认为变量已经被初始化:

function foo() {
    let result: number;
    if (someCondition()) {
        result = doSomeWork();
    } else {
        let temporaryWork = doSomeWork();
        temporaryWork *= 2;
        // 忘记将值赋给 'result'
    }

    printResult();

    function printResult() {
        console.log(result); // 这里没有报错。
    }
}

在 TypeScript 5.7 中,尽管对于可能已初始化的变量类型系统仍保持宽松,但它能够对完全未初始化的变量报告错误:

function foo() {
    let result: number;
    
    // 执行了一些操作,但忘记赋值给 'result'

    function printResult() {
        console.log(result); // 错误:变量 'result' 在使用前未赋值。
    }
}

这一改进得益于 GitHub 用户 Zzzen 的贡献!

相对路径的路径重写

许多工具和运行时允许你“原地”运行 TypeScript 代码,这意味着不需要一个生成输出 JavaScript 文件的构建步骤。例如,ts-nodetsxDenoBun 都支持直接运行 .ts 文件。最近,Node.js 也在探索此类支持功能,例如 --experimental-strip-types(即将去掉实验标志!)和 --experimental-transform-types。这种功能非常方便,因为它让我们可以更快地迭代,而不必担心反复执行构建任务。

不过,在使用这些模式时有些复杂性需要注意。为了最大程度地兼容所有这些工具,原地运行的 TypeScript 文件在运行时必须以适当的 TypeScript 扩展名进行导入。例如,在 Node.js 的新实验功能中,要导入名为 foo.ts 的文件,我们需要这样写:

// main.ts

import * as foo from "./foo.ts"; // <- 这里需要使用 foo.ts,而不是 foo.js

通常情况下,TypeScript 会对此发出错误,因为它期望我们导入的是输出文件。由于某些工具确实允许 .ts 导入,TypeScript 已经通过 --allowImportingTsExtensions 选项支持这种导入方式。但如果我们需要将这些 .ts 文件生成 .js 文件呢?这是库作者必须面对的要求,他们需要分发 .js 文件。而之前,TypeScript 一直避免重写任何路径。

为了支持这种情况,我们新增了一个编译器选项 --rewriteRelativeImportExtensions。当导入路径是相对路径(以 ./../ 开头)、以 TypeScript 扩展名(如 .ts.tsx.mts.cts)结尾,并且是非声明文件时,编译器会将路径重写为对应的 JavaScript 扩展名(如 .js.jsx.mjs.cjs)。

// 在 --rewriteRelativeImportExtensions 下...

// 这些路径将被重写。
import * as foo from "./foo.ts";
import * as bar from "../someFolder/bar.mts";

// 这些路径不会被重写。
import * as a from "./foo";
import * as b from "some-package/file.ts";
import * as c from "@some-scope/some-package/file.ts";
import * as d from "#/file.ts";
import * as e from "./file.js";

这让我们可以编写既能原地运行又能在需要时编译为 JavaScript 的 TypeScript 代码。


尽管如此,TypeScript 通常避免重写路径,主要原因之一是动态导入。例如,开发者编写以下代码时,很难处理动态导入接收到的路径:

function getPath() {
    if (Math.random() < 0.5) {
        return "./foo.ts";
    } else {
        return "./foo.js";
    }
}

let myImport = await import(getPath());

另一个问题是(如上所示)只有相对路径会被重写,并且它们的重写是“简单的”。这意味着依赖 TypeScript 的 baseUrlpaths 的路径不会被重写:

// tsconfig.json

{
    "compilerOptions": {
        "module": "nodenext",
        // ...
        "paths": {
            "@/*": ["./src/*"]
        }
    }
}

// 不会被转换,因此不起作用。
import * as utilities from "@/utilities.ts";

同样,通过 package.jsonexportsimports 字段解析的路径也不会被重写:

// package.json
{
    "name": "my-package",
    "imports": {
        "#root/*": "./dist/*"
    }
}

// 不会被转换,因此不起作用。
import * as utilities from "#root/utilities.ts";

因此,如果你在使用多包互相引用的工作区式布局,可能需要使用条件导出和自定义条件来实现:

// my-package/package.json

{
    "name": "my-package",
    "exports": {
        ".": {
            "@my-package/development": "./src/index.ts",
            "import": "./lib/index.js"
        },
        "./*": {
            "@my-package/development": "./src/*.ts",
            "import": "./lib/*.js"
        }
    }
}

每当你想导入 .ts 文件时,可以使用以下命令运行:

node --conditions=@my-package/development

请注意,这里使用了 @my-package/development 作为条件的“命名空间”或“作用域”。这是一种权宜之计,用来避免其他依赖也使用 development 条件时产生冲突。如果每个包都提供一个 development 条件,解析可能会尝试导入 .ts 文件,而这不一定能正常工作。这种思路类似于 Colin McDonnell 在文章 Live types in a TypeScript monorepo 中提到的做法,以及 tshy 提供的从源代码加载的建议。


支持 --target es2024--lib es2024

TypeScript 5.7 现在支持 --target es2024,允许用户针对 ECMAScript 2024 运行时。这一目标主要启用了新的 --lib es2024,其中包含许多新特性,如 SharedArrayBufferArrayBuffer 的增强功能、Object.groupByMap.groupByPromise.withResolvers 等。此外,Atomics.waitAsync 已从 --lib es2022 移至 --lib es2024

注意,由于 SharedArrayBufferArrayBuffer 的变化,两者现在有所分离。为了弥合差距并保留底层缓冲区类型,所有 TypedArray(如 Uint8Array 等)现在也变为泛型类型:

interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
    // ...
}

每个 TypedArray 都包含一个名为 TArrayBuffer 的类型参数,不过该参数具有默认值,因此仍可以直接使用 Int32Array,而无需显式写出 Int32Array<ArrayBufferLike>

如果在更新后遇到任何问题,可能需要更新 @types/node

这一工作主要得益于 Kenta Moriuchi 的贡献!

搜索Root配置文件以确定项目归属

当使用 TSServer(如 Visual Studio 或 VS Code)在编辑器中加载 TypeScript 文件时,编辑器会尝试找到与该文件相关的 tsconfig.json 文件以确定其归属。为了实现这一点,编辑器会从当前编辑的文件所在目录向上查找,寻找名为 tsconfig.json 的文件。

此前的行为:
这个搜索过程会在找到第一个 tsconfig.json 文件时停止;但假设项目结构如下:

project/
├── src/
│   ├── foo.ts
│   ├── foo-test.ts
│   ├── tsconfig.json
│   └── tsconfig.test.json
└── tsconfig.json

在上述结构中,src/tsconfig.json 是项目的主要配置文件,而 src/tsconfig.test.json 是用于运行测试的配置文件。

// src/tsconfig.json
{
    "compilerOptions": {
        "outDir": "../dist"
    },
    "exclude": ["**/*.test.ts"]
}
// src/tsconfig.test.json
{
    "compilerOptions": {
        "outDir": "../dist/test"
    },
    "include": ["**/*.test.ts"],
    "references": [
        { "path": "./tsconfig.json" }
    ]
}
// tsconfig.json
{
    "files": [],
    "references": [
        { "path": "./src/tsconfig.json" },
        { "path": "./src/tsconfig.test.json" }
    ]
}

在这种情况下,编辑 foo-test.ts 时,编辑器会找到 project/src/tsconfig.json 作为“归属”配置文件——但这并不是我们期望的文件!这种情况下,搜索中止可能会带来不便。过去,唯一的解决方法是将 src/tsconfig.json 重命名为类似 src/tsconfig.src.json,以确保所有文件能找到顶层的 tsconfig.json,该文件引用了所有可能的子项目:

project/
├── src/
│   ├── foo.ts
│   ├── foo-test.ts
│   ├── tsconfig.src.json
│   └── tsconfig.test.json
└── tsconfig.json

TypeScript 5.7 引入了一个改进,编辑器场景下的搜索会继续向上查找目录树,以找到其他合适的 tsconfig.json 文件。这为项目的组织方式和配置文件的结构提供了更大的灵活性。

有关实现的更多具体信息,请参见此处


更快的复合项目编辑器归属检查

假设一个大型代码库如下:

packages/
├── graphics/
│   ├── tsconfig.json
│   └── src/
│       └── ...
├── sound/
│   ├── tsconfig.json
│   └── src/
│       └── ...
├── networking/
│   ├── tsconfig.json
│   └── src/
│       └── ...
├── input/
│   ├── tsconfig.json
│   └── src/
│       └── ...
└── app/
    ├── tsconfig.json
    ├── some-script.js
    └── src/
        └── ...

每个 packages 目录都是一个独立的 TypeScript 项目,而 app 目录是主项目,依赖所有其他子项目。

// app/tsconfig.json
{
    "compilerOptions": {
        // ...
    },
    "include": ["src"],
    "references": [
        { "path": "../graphics/tsconfig.json" },
        { "path": "../sound/tsconfig.json" },
        { "path": "../networking/tsconfig.json" },
        { "path": "../input/tsconfig.json" }
    ]
}

注意,这里有一个文件 some-script.js 位于 app 目录。当在编辑器中打开 some-script.js 时,TypeScript 语言服务需要确定该文件属于哪个项目,以应用正确的设置。

此前的行为:
在这种情况下,最近的 tsconfig.json 文件不包含 some-script.js。因此,TypeScript 会尝试检查 app/tsconfig.json 所引用的每个项目,看是否包含 some-script.js。过去的做法是逐一加载每个项目,并在找到包含该文件的项目时停止。然而,即使 some-script.js 不在根文件集中,TypeScript 仍会解析项目中的所有文件,因为根文件集中的一些文件可能会通过依赖关系间接引用 some-script.js

这种行为在大型代码库中可能导致极端且不可预测的性能问题。开发者打开某些独立脚本文件时,可能会发现整个代码库被加载,严重影响效率。

TypeScript 5.7 的改进:
每个可以被其他项目引用的项目(非工作区项目)都必须启用 composite 标志,该标志要求所有输入源文件必须提前声明。因此,在检查复合项目时,TypeScript 5.7 只会验证文件是否属于项目的根文件集,从而避免最糟糕的情况。

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

--module nodenext 中验证 JSON 导入

在使用 --module nodenext 时,从 .json 文件导入将需要遵循特定规则,以防止运行时错误。

规则 1:必须提供 type: "json" 的导入属性

当导入 JSON 文件时,必须包含带有 type: "json" 的导入属性,否则会报错:

import myConfig from "./myConfig.json";
//                   ~~~~~~~~~~~~~~~~~
// ❌ 错误:在 ECMAScript 模块中导入 JSON 文件时,如果 'module' 设置为 'NodeNext',需要使用 'type: "json"' 的导入属性。

import myConfig from "./myConfig.json" with { type: "json" };
//                                          ^^^^^^^^^^^^^^^^
// ✅ 正确:因为我们提供了 `type: "json"`
规则 2:不支持“命名”导出

导入 JSON 文件的内容只能通过默认导入访问,TypeScript 不会生成命名导出。

// ✅ 这是正确的方式:
import myConfigA from "./myConfig.json" with { type: "json" };
let version = myConfigA.version;

///////////

import * as myConfigB from "./myConfig.json" with { type: "json" };

// ❌ 错误:不能直接访问命名导出
let version = myConfig.version;

// ✅ 正确:通过默认导入访问
let version = myConfig.default.version;

更多细节可以参见此处


支持 Node.js 中的 V8 编译缓存

Node.js 22 新增了 module.enableCompileCache() API,允许运行时重用工具在首次运行后的一部分解析和编译工作。

TypeScript 5.7 开始利用该 API,以提高工具的启动速度。在测试中,运行 tsc --version 的速度提高了约 2.5 倍:

Benchmark 1: node ./built/local/_tsc.js --version (*未启用* 缓存)
  平均时间 (mean ± σ):     122.2 ms ±   1.5 ms    [用户时间: 101.7 ms, 系统时间: 13.0 ms]
  范围 (min … max):   119.3 ms … 132.3 ms    200 次运行

Benchmark 2: node ./built/local/tsc.js --version  (*启用* 缓存)
  平均时间 (mean ± σ):      48.4 ms ±   1.0 ms    [用户时间: 34.0 ms, 系统时间: 11.1 ms]
  范围 (min … max):    45.7 ms …  52.8 ms    200 次运行

总结:
  启用缓存后运行速度
    比未启用缓存快了 2.52 ± 0.06 倍

更多信息可以参见此处


重要变更

以下列出了一些值得注意的变更,这些变更可能会影响现有的代码库:


1. lib.d.ts 的更新

针对 DOM 的类型定义更新可能会影响代码库的类型检查。更多详情请参阅与 DOM 和 lib.d.ts 更新相关的 issues


2. TypedArray 现在支持泛型

在 ECMAScript 2024 中,SharedArrayBufferArrayBuffer 的类型定义有所分歧。为了弥合这些差异并保留底层缓冲区类型,所有 TypedArray(如 Uint8Array 等)现在都支持泛型:

interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
    // ...
}

每个 TypedArray 现在包含一个名为 TArrayBuffer 的类型参数。不过该类型参数有一个默认值,因此可以继续使用 Int32Array 而无需显式写出 Int32Array<ArrayBufferLike>

可能遇到的问题

在更新后,可能会遇到以下错误:

error TS2322: Type 'Buffer' is not assignable to type 'Uint8Array<ArrayBufferLike>'.
error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'Uint8Array<ArrayBufferLike>'.
error TS2345: Argument of type 'ArrayBufferLike' is not assignable to parameter of type 'ArrayBuffer'.
error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'string | ArrayBufferView | Stream | Iterable<string | ArrayBufferView> | AsyncIterable<string | ArrayBufferView>'.

解决方法:
如果遇到这些问题,可能需要更新 @types/node

更多细节请参阅此处

在类中用非字面量方法名创建索引签名

TypeScript 现在对类中用非字面量计算属性名声明的方法的行为更加一致。例如,以下代码:

declare const symbolMethodName: symbol;

export class A {
    [symbolMethodName]() { return 1; }
}

在 TypeScript 以前的版本中,上述代码中的 [symbolMethodName] 方法不会对 A 的类型系统产生任何影响,等效于:

export class A {
}

换句话说,从类型系统的角度来看,[symbolMethodName]A 的类型没有任何贡献。

在 TypeScript 5.7 中,方法 [symbolMethodName]() 会被更有意义地处理,并生成索引签名。因此,上述代码会被解释为:

export class A {
    [x: symbol]: () => number;
}

这使得它的行为与对象字面量中的属性和方法保持一致。

更多关于此更改的信息,请查看这里


对返回 nullundefined 的函数的更多隐式 any 错误

当函数表达式在上下文中由返回泛型类型的签名类型化时,TypeScript 现在会在 noImplicitAny 开启但 strictNullChecks 关闭的情况下,正确提供隐式 any 的错误。

示例

以下代码中,catch 的回调返回 null

declare var p: Promise<number>;
const p2 = p.catch(() => null);
//                 ~~~~~~~~~~
// error TS7011: Function expression, which lacks return-type annotation, implicitly has an 'any' return type.

在这种情况下,TypeScript 会抛出错误,提示缺少返回类型注解。

更多详情请参见此更改


下一步是什么?

很快,我们将分享下一版本 TypeScript 的计划细节。如果您想提前体验最新的修复和功能,可以通过以下方式获取:

  1. 在 npm 上使用 TypeScript 的 nightly 构建版本
  2. 安装适用于 Visual Studio Code 的扩展,以使用这些 nightly 版本。

我们希望 TypeScript 5.7 能让您的编码过程更加愉快。Happy Hacking!

—— Daniel Rosenwasser 和 TypeScript 团队

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,137评论 6 511
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,824评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,465评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,131评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,140评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,895评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,535评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,435评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,952评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,081评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,210评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,896评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,552评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,089评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,198评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,531评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,209评论 2 357

推荐阅读更多精彩内容