TypeScript 5.8

返回表达式中分支的细粒度检查(Granular Checks for Branches in Return Expressions)

来看这样一段代码:

declare const untypedCache: Map<any, any>;
function getUrlObject(urlString: string): URL {
    return untypedCache.has(urlString) ?
        untypedCache.get(urlString) :
        urlString;
}

这段代码的意图是:如果缓存中存在某个 URL 对应的对象,就从缓存中获取;否则就创建一个新的 URL 对象。但这里有个 bug:我们忘了在 else 分支中真正用输入构造一个新的 URL 对象。

不幸的是,TypeScript 过去通常无法捕捉到这种错误。

当 TypeScript 检查像 cond ? trueBranch : falseBranch 这样的条件表达式时,它会将整个表达式的类型视为两个分支类型的联合类型。也就是说,它获取 trueBranchfalseBranch 的类型,并将它们合并为一个联合类型。在这个例子中,untypedCache.get(urlString) 的类型是 any,而 urlString 的类型是 string。问题就出在这里:any 类型在与其他类型交互时具有“传染性”。联合类型 any | string 会被简化为 any。所以当 TypeScript 最终检查 return 语句的表达式是否符合函数声明的返回类型 URL 时,类型系统已经失去了识别这个 bug 的能力。

在 TypeScript 5.8 中,类型系统对直接位于 return 语句中的条件表达式进行了特殊处理。条件的每个分支都会与包含该函数的声明返回类型(如果有)进行对比,因此能够识别出上面的代码中的错误。

declare const untypedCache: Map<any, any>;
function getUrlObject(urlString: string): URL {
    return untypedCache.has(urlString) ?
        untypedCache.get(urlString) :
        urlString;
    //  ~~~~~~~~~
    // 错误!类型 'string' 不能赋值给类型 'URL'。
}

这个改动是通过这个 Pull Request 引入的,它是对 TypeScript 类型系统未来一系列改进的一部分。

--module nodenext 下对 require() 引入 ECMAScript 模块的支持

多年来,Node.js 同时支持 ECMAScript 模块(ESM)和 CommonJS 模块。但它们之间的互操作性存在一些挑战:

  • ESM 文件可以 import CommonJS 文件
  • CommonJS 文件不能 require() ESM 文件

换句话说,从 ESM 引入 CommonJS 是可行的,但反过来却不行。这为那些希望支持 ESM 的库作者带来了很多麻烦。他们要么需要放弃对 CommonJS 用户的支持,要么进行“双重发布”(为 ESM 和 CommonJS 分别提供入口文件),要么干脆一直停留在 CommonJS 上。虽然“双重发布”听起来像是一个折中方案,但它是一个复杂且容易出错的过程,并且会让包的体积几乎翻倍。

Node.js 22 放宽了这些限制,允许 CommonJS 模块中使用 require("esm") 引入 ECMAScript 模块。虽然仍不支持引入包含顶层 await 的 ESM 文件,但大多数其他 ESM 文件现在都可以被 CommonJS 文件引入。这为库作者带来了重大机遇:他们可以支持 ESM 而无需双重发布。

TypeScript 5.8 在 --module nodenext 模式下支持这一行为。当启用 --module nodenext 时,TypeScript 不会对这些 require() 引入 ESM 的调用报错。

由于该功能可能会被回溯性支持到旧版 Node.js,目前还没有一个稳定的 --module nodeXXXX 可用于开启该行为;不过我们预计未来版本的 TypeScript 可能会在 node20 模式下稳定此特性。在此之前,我们建议使用 Node.js 22 或更新版本的用户启用 --module nodenext,而库作者或使用旧版 Node.js 的用户则继续使用 --module node16,或升级到 --module node18

更多信息详见:require("esm") 的支持说明

--module node18

TypeScript 5.8 新增了一个稳定的 --module node18 标志。对于坚持使用 Node.js 18 的用户来说,这个标志提供了一个不包含 --module nodenext 某些行为的稳定选择。具体区别如下:

  • node18不允许 使用 require() 引入 ESM 模块;而在 nodenext 下是允许的
  • import 断言(已被 import attributes 取代)在 node18 下仍被支持;但在 nodenext 中则不支持

详细内容可见:


--erasableSyntaxOnly 选项

最近,Node.js 23.6 取消了对直接运行 TypeScript 文件的实验性支持的限制;但只有部分语法在该模式下被支持。

Node.js 目前提供了一个名为 --experimental-strip-types 的模式,它要求 TypeScript 的语法不能带有运行时语义。换句话说,必须可以轻松地“擦除”任何 TypeScript 专属语法,使剩下的代码是合法的 JavaScript。

这意味着以下语法结构不被支持

  • enum 声明
  • 含有运行时代码的 namespacemodule
  • 类中的参数属性(parameter properties)
  • 非 ECMAScript 风格的 import =export =

以下是一些不被支持的示例:

enum Color {
    Red,
    Green,
    Blue
}

namespace MyNamespace {
    export const value = 42;
}

class Person {
    constructor(public name: string) {} // 参数属性
}

import foo = require("foo");

这些语法在 --erasableSyntaxOnly 或 Node.js 的 --experimental-strip-types 模式下将会导致错误。

以下是你提供内容的完整中文翻译:


❌ 错误示例:

// ❌ 错误:`import ... = require(...)` 的别名写法
import foo = require("foo");

// ❌ 错误:带有运行时代码的命名空间
namespace container {
}

// ❌ 错误:`import =` 的别名写法
import Bar = container.Bar;

class Point {
    // ❌ 错误:构造函数中的参数属性写法
    constructor(public x: number, public y: number) { }
}

// ❌ 错误:`export =` 的导出方式
export = Point;

// ❌ 错误:枚举声明
enum Direction {
    Up,
    Down,
    Left,
    Right,
}

类似的工具,比如 ts-blank-space 或 Node.js 中用于类型剥离的底层库 Amaro,也有相同的限制。这些工具在遇到不符合要求的代码时会给出友好的错误信息,但你仍然需要真正运行代码才能发现这些问题。

因此,TypeScript 5.8 引入了 --erasableSyntaxOnly 选项。当启用该选项时,TypeScript 会对大多数具有运行时代码语义的 TypeScript 特有语法报错。

class C {
    constructor(public x: number) { }
    //          ~~~~~~~~~~~~~~~~
    // 错误!启用 'erasableSyntaxOnly' 时不允许使用该语法。
}

通常你会希望将此选项与 --verbatimModuleSyntax 结合使用,以确保模块使用的是正确的导入语法,并且不会发生导入消除(import elision)。

👉 更多信息可见实现 PR

--libReplacement 标志

在 TypeScript 4.5 中,我们引入了用自定义 lib 文件替代默认 lib 文件的能力。这是通过从名为 @typescript/lib-* 的包中解析库文件实现的。例如,你可以通过如下方式将 DOM 库锁定到特定版本的 @types/web 包:

{
    "devDependencies": {
       "@typescript/lib-dom": "npm:@types/web@0.0.199"
     }
}

当安装好以后,TypeScript 将查找名为 @typescript/lib-dom 的包(如果 lib 设置中使用了 dom),无论你是否使用了该功能,TypeScript 都会默认执行这个查找,并监听 node_modules 的变化。

在 TypeScript 5.8 中引入了 --libReplacement 标志,你可以使用 --libReplacement false 来关闭这种行为。如果你依赖这种替换机制,请明确启用它,例如使用 --libReplacement true。未来 false 可能会成为默认值。

👉 详情见更新内容

声明文件中的计算属性名保留行为

为使计算属性在声明文件中的输出更可预测,TypeScript 5.8 将在类的计算属性中一致保留实体名(如 bareVariablesdotted.names.that.look.like.this)。

例如,考虑以下代码:

export let propName = "theAnswer";
export class MyClass {
    [propName] = 42;
    //  ~~~~~~~~~~
    // 错误!类属性声明中的计算属性名必须是简单字面量类型或 'unique symbol' 类型。
}

在旧版本中,TypeScript 会报错,并尝试生成如下 best-effort 的声明文件:

export declare let propName: string;
export declare class MyClass {
    [x: string]: number;
}

而在 TypeScript 5.8 中,这段代码将被允许,并生成如下声明文件:

export declare let propName: string;
export declare class MyClass {
    [propName]: number;
}

注意,这不会创建静态命名属性,而仍然相当于 [x: string]: number 的形式。如果你需要静态属性,应该使用 unique symbol 或字面量类型。

⚠️ 使用 --isolatedDeclarations 时此类代码仍然会报错。但我们预计,随着这一变化的引入,计算属性名将更广泛地在声明文件中被允许。

👉 详情见实现 PR

程序加载与更新优化

TypeScript 5.8 引入了多项优化,提升构建程序及响应文件变更(如 --watch 模式或编辑器场景)时的性能:

  • 路径归一化优化:不再通过字符串数组操作来处理路径,而是直接基于索引操作原始路径,避免了大量数组创建和连接。
  • 配置项缓存:对于不改变项目结构的编辑,TypeScript 不再重复验证如 tsconfig.json 的配置,而是复用上一次的校验结果,从而提高响应速度,尤其在大型项目中更为明显。

行为变更汇总(需关注的重要变更)

  • lib.d.ts 更新:DOM 类型变更可能会影响你代码库的类型检查。请参见相关 issue 获取更多细节。

--module nodenext 下的 import 断言限制

ECMAScript 曾提出 import assertions,用于确保导入模块的某些特性(例如确保是 JSON 文件)。后来该提议演变成 import attributes,并用 with 替换了 assert 关键字:

// ❌ import assertion(旧提案写法,不兼容)
import data from "./data.json" assert { type: "json" };

// ✅ import attribute(新提案写法)
import data from "./data.json" with { type: "json" };

Node.js 22 不再支持 assert 语法的 import 断言。因此,在 TypeScript 5.8 中,当启用了 --module nodenext,使用旧写法将会报错:

import data from "./data.json" assert { type: "json" };
//                             ~~~~~~
// 错误!import assertions 已被 import attributes 取代,请使用 'with' 替代 'assert'。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容