返回表达式中分支的细粒度检查(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
这样的条件表达式时,它会将整个表达式的类型视为两个分支类型的联合类型。也就是说,它获取 trueBranch
和 falseBranch
的类型,并将它们合并为一个联合类型。在这个例子中,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
声明 - 含有运行时代码的
namespace
和module
- 类中的参数属性(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 将在类的计算属性中一致保留实体名(如 bareVariables
或 dotted.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'。