erasableSyntaxOnly
Refer
Source
: TypeScript 5.8 Ships --erasableSyntaxOnly To Disable Enums - February 5, 2025
Author
: Matt Pocock
Translator
: SenaoXi
A Note to Our Readers
While maintaining fidelity to the source material, this translation incorporates explanatory content and localized expressions, informed by the translator's domain expertise. These thoughtful additions aim to enhance readers' comprehension of the original text's core messages. For any inquiries about the content, we welcome you to engage in the discussion section or consult the source text for reference.
typescript
在 v5.8
版本中引入了一个新的编译器标志 erasableSyntaxOnly
。
erasableSyntaxOnly
是为 node
准备的预检查机制,有助于 node
原生支持类型剥离特性(v22.6.0
添加 --experimental-strip-types
, v23.6.0
版本中默认启用类型剥离)的实施。
erasableSyntaxOnly
在类型限制上比 isolatedModules
更为严格,isolatedModules
是为 bundlers
服务的,bundlers
要求能够独立编译模块而不需要跨模块来获知所有上下文信息后再进行编译,同时编译类型本身就是 bundlers
的职责,因此对 bundlers
来说,仅限制跨模块分析的类型即可。
而 node
会对可擦除的类型以空格替换,同时并不会有意为 typescript
类型做额外的转译工作。因此对 bundlers
来说,除了限制跨模块分析的类型外还需要额外限制具有运行时行为的类型。
小结
isolatedModules
是为 bundlers
编译模块而服务的,关注的是模块的可独立转译性。
erasableSyntaxOnly
是为 node
原生解析 typescript
模块而服务的,关注的是模块的纯类型性,即不引入 typescript
特有的运行时结构。
另一方面,从类型约束的严格性来说,erasableSyntaxOnly
是 isolatedModules
的子集。
因此若项目中开启 erasableSyntaxOnly
编译器选项,那么就可以将 isolatedModules
编译器选项关闭,这也是 vrite
项目中采取的策略,详情可见 feat: bump TS to 5.8 中的支持。
那么继续讲解下 erasableSyntaxOnly
不适用的情况,以下例子均为 不可擦除
的类型:
枚举(
enums
)enum
:编译为对象,不涉及到跨模块分析,符合isolatedModules
的规范,但需额外生成代码,不符合erasableSyntaxOnly
的规范。const enum
:相比于enum
,并不会编译为对象,但会采用 内联替换 策略,在用到的地方直接替换。因此需要完整分析模块依赖图后,获得全部上下文信息后才能确定哪些模块中使用到了const enum
并执行 内联替换 策略。这就要求在解析const enum
时,需要额外维护const enum
的依赖关系,这违背了isolatedModules
的初衷。因此在开启isolatedModules
的情况下,const enum
在同一模块和跨模块使用时,会采取不一样的策略。前者会执行 内联替换 策略,后者会编译为对象。既不符合isolatedModules
的规范,也不符合erasableSyntaxOnly
的规范。as const
:这是erasableSyntaxOnly
模式下的最佳实践,不涉及跨模块分析,符合isolatedModules
的规范,同时也不会生成额外的代码,符合erasableSyntaxOnly
的规范。
tsexport const Colors = { Red: 0, Green: 1, Blue: 2 } as const; export type Color = (typeof Colors)[keyof typeof Colors];
具有运行时代码的
namespace
和module
namespace
本质上早期(es2015
之前)实现模块化的方案,编译后的形式类似于javascript
中的立即调用函数表达式(iife
)。tsnamespace UI { export namespace Components { export class Button { } } export const version = '1.3.0'; }
ts"use strict"; var UI; (function (UI) { let Components; (function (Components) { class Button { } Components.Button = Button; })(Components = UI.Components || (UI.Components = {})); UI.version = '1.3.0'; })(UI || (UI = {}));
namespace
(及其别名module
关键字)支持跨模块同名合并,同时还需编译为iife
形式,因此不符合erasableSyntaxOnly
和isolatedModules
的规范。类的参数属性
参数属性是
typescript
中的一种语法,用于在类构造函数中直接声明和初始化类的成员变量。tsclass Point { constructor( public x: number, public y: number ) {} }
tsclass Point { constructor(x, y) { this.x = x; this.y = y; } }
从上例的编译结果可知需要依赖编译器自动生成属性初始化代码,由于以下原因,这并不符合 可擦除类型 的规范。
- 生成额外代码:编译器会自动生成属性赋值语句。
- 改变运行时行为:创建了类的实例属性。
- 影响对象结构:决定了实例上会存在哪些属性。
本质上,参数属性是一种既有类型信息又有运行时效果的 混合特性。它不仅仅是类型系统的一部分,还会影响编译后的
javascript
代码。替换方案:
tsclass Point { x: number; y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } }
非
ECMAScript
规范的import =
和export =
赋值export =
和import =
被称为 非标准 规范,这是typescript
特有的语法,设计用来桥接commonjs/amd
与typescript
的静态类型系统。会额外生成代码,根据module
编译选项(如 "commonjs", "amd", "umd" 等)生成对应的代码tsclass Calculator { add(x: number, y: number): number { return x + y; } } export = Calculator;
tsclass Calculator { add(x, y) { return x + y; } } module.exports = Calculator;
tsdefine(['require', 'exports'], function (require, exports) { 'use strict'; class Calculator { add(x, y) { return x + y; } } return Calculator; });
ts(function (factory) { if (typeof module === 'object' && typeof module.exports === 'object') { var v = factory(require, exports); if (v !== undefined) module.exports = v; } else if (typeof define === 'function' && define.amd) { define(['require', 'exports'], factory); } })(function (require, exports) { 'use strict'; class Calculator { add(x, y) { return x + y; } } return Calculator; });
这与
erasableSyntaxOnly
并不兼容,因为这不仅仅是在类型检查阶段起作用,还需要生成包含复杂的模块封装和加载逻辑的运行时代码。在现代
typescript
项目中,建议使用标准的ES
模块语法:ts// 导出(替代 export =) export default Calculator; // 导入(替代 import = require) import Calculator from './math';
若使用 erasableSyntaxOnly
编译器选项,以下代码会报错:
// Error! Not allowed
enum Example {
foo
}
// Error! Not allowed
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace OhNo {
export const foo = 1;
}
class X {
// Error! Not allowed
constructor(private foo: string) {}
}
What Does 'erasable' Mean?
可擦除
的类型语法指的是:当我们删除这些类型语法结构时,代码的运行时行为不会受到影响。
这是一个非常重要的概念,让我们通过一个简单的例子来理解。
普通的类型注解 就是 可擦除
的:
// 带类型注解的代码
const name: string = 'TypeScript';
// 删除类型注解后的代码
const name = 'TypeScript';
可以看到,删除 : string
类型注解后,代码依然是合法的 javascript
,而且运行结果完全相同。
函数参数的类型注解 也是如此:
// 带参数类型的函数
function greet(name: string) {
console.log(`Hello, ${name}!`);
}
// 删除参数类型后的函数
function greet(name) {
console.log(`Hello, ${name}!`);
}
但是 枚举
、命名空间
和 类参数属性
这些类型语法并不是 可擦除
的,因为这些类型语法在编译阶段会额外生成 javascript
代码:
enum Example {
foo
}
// typescript compiler will generate the following code
var Example;
(function (Example) {
Example[(Example['foo'] = 0)] = 'foo';
})(Example || (Example = {}));
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace OhNo {
export const foo = 1;
}
// typescript compiler will generate the following code
var OhNo;
(function (OhNo) {
OhNo.foo = 1;
})(OhNo || (OhNo = {}));
class X {
constructor(private foo: string) {}
}
// typescript compiler will generate the following code
class X {
foo;
constructor(foo) {
this.foo = foo;
}
}
构建工具需要通过 复杂的逻辑 来处理这些语法:
rollup
会使用@rollup/plugin-typescript
插件来编译typescript
模块,插件内部会使用到typescript
的compiler api
来转译这些复杂的语法,生成额外的运行时代码。esbuild
内置了对于typescript
的转译工作,可以将复杂的typescript
类型转换为javascript
代码。
Why Is erasableSyntaxOnly
Being Added?
引入 erasableSyntaxOnly
有几个重要原因:
Default support for Node.js
通过 type-stripping
文章的介绍中可知,node.js
在 v22.6.0
版本中添加了对 typescript
的类型移除的实验性支持,并在 v23.6.0
版本中默认启用类型剥离。
但是 node.js
默认只会执行那些不需要转换的 typescript
特性(即 可擦除
的类型)的模块。在这种模式下,node.js
会将内联的 类型注解 替换为 空白字符,并且不会执行任何类型检查。
Additional Feature Support
如果需要支持更复杂的 typescript
特性转换(即 不可擦除
的类型),可以使用 --experimental-transform-types
标志,这个特性在 node.js
的 v22.7.0
版本中实验性支持。
需要转换的最主要特性包括:
- Enum(枚举)
- namespaces(命名空间)
- legacy module(遗留模块)
- parameter properties(参数属性)
此外,node.js
不会读取 tsconfig.json
文件,也不支持依赖于 tsconfig.json
中设置的特性,比如 路径别名 或 将较新的 javascript
语法转换为较旧标准。
可擦除语法
类型特性代表了 node.js
在提供 typescript
支持方面的一个重要进展,它提供了一个轻量级的解决方案,使开发者能够直接在 node.js
中运行 typescript
代码,同时避免了完整 typescript
编译过程的复杂性。提前启用 erasableSyntaxOnly
是一个很好的选择,通过避免使用 不可擦除
的类型特性,使得 typescript
模块可以直接运行在 node.js
环境中。
Typescript Future Prospect
这反映了 typescript
团队对未来的展望,复杂语法将来可能会被其他形式的语法替代或者是不再使用。
目前有几个提案正在讨论是否向 javascript
添加类型,其中最受欢迎的是"类型即注释"(types as comments
)提案。该提案允许 javascript
引擎将类型声明视为可以在运行时忽略的注释。
比如下面的代码在未来可能成为合法的 javascript
:
// 带类型注解的代码
const name: string = 'TypeScript';
// 删除类型注解后的代码
const name = 'TypeScript';
这将是生态系统的重大进步,离在浏览器中直接运行 typescript
更近了一步。但是,这种方案只适用于 可擦除语法。像 枚举 或 命名空间 等需要复杂转换的特性就无法适配 类型即注释 特性。
What About Import Aliases?
导入别名(通过 paths
选项配置)是处理导入的一种流行方式。不幸的是,在使用 erasableSyntaxOnly
时这种方式是无法工作。
推荐的解决方案是依赖于非 typescript
方法来处理这个问题:package.json#imports
。typescript
和 node.js
默认支持此功能。
{
"imports": {
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
},
"dependencies": {
"dep-node-native": "^1.0.0"
}
}