Skip to content

erasableSyntaxOnly

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.

typescriptv5.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 特有的运行时结构。

另一方面,从类型约束的严格性来说,erasableSyntaxOnlyisolatedModules 的子集。

因此若项目中开启 erasableSyntaxOnly 编译器选项,那么就可以将 isolatedModules 编译器选项关闭,这也是 vrite 项目中采取的策略,详情可见 feat: bump TS to 5.8 中的支持。

那么继续讲解下 erasableSyntaxOnly 不适用的情况,以下例子均为 不可擦除 的类型:

  1. 枚举(enums)

    • enum:编译为对象,不涉及到跨模块分析,符合 isolatedModules 的规范,但需额外生成代码,不符合 erasableSyntaxOnly 的规范。
    • const enum:相比于 enum,并不会编译为对象,但会采用 内联替换 策略,在用到的地方直接替换。因此需要完整分析模块依赖图后,获得全部上下文信息后才能确定哪些模块中使用到了 const enum 并执行 内联替换 策略。这就要求在解析 const enum 时,需要额外维护 const enum 的依赖关系,这违背了 isolatedModules 的初衷。因此在开启 isolatedModules 的情况下,const enum 在同一模块和跨模块使用时,会采取不一样的策略。前者会执行 内联替换 策略,后者会编译为对象。既不符合 isolatedModules 的规范,也不符合 erasableSyntaxOnly 的规范。
    • as const:这是 erasableSyntaxOnly 模式下的最佳实践,不涉及跨模块分析,符合 isolatedModules 的规范,同时也不会生成额外的代码,符合 erasableSyntaxOnly 的规范。
    ts
    export const Colors = {
      Red: 0,
      Green: 1,
      Blue: 2
    } as const;
    
    export type Color = (typeof Colors)[keyof typeof Colors];
  2. 具有运行时代码的 namespacemodule

    namespace 本质上早期(es2015 之前)实现模块化的方案,编译后的形式类似于 javascript 中的立即调用函数表达式(iife)。

    ts
      namespace 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 形式,因此不符合 erasableSyntaxOnlyisolatedModules 的规范。

  3. 类的参数属性

    参数属性是 typescript 中的一种语法,用于在类构造函数中直接声明和初始化类的成员变量。

    ts
    class Point {
      constructor(
        public x: number,
        public y: number
      ) {}
    }
    ts
    class Point {
      constructor(x, y) {
        this.x = x;
        this.y = y;
      }
    }

    从上例的编译结果可知需要依赖编译器自动生成属性初始化代码,由于以下原因,这并不符合 可擦除类型 的规范。

    • 生成额外代码:编译器会自动生成属性赋值语句。
    • 改变运行时行为:创建了类的实例属性。
    • 影响对象结构:决定了实例上会存在哪些属性。

    本质上,参数属性是一种既有类型信息又有运行时效果的 混合特性。它不仅仅是类型系统的一部分,还会影响编译后的 javascript 代码。

    替换方案:

    ts
    class Point {
      x: number;
      y: number;
    
      constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
      }
    }
  4. ECMAScript 规范的 import =export = 赋值

    export =import = 被称为 非标准 规范,这是 typescript 特有的语法,设计用来桥接 commonjs/amdtypescript 的静态类型系统。会额外生成代码,根据 module 编译选项(如 "commonjs", "amd", "umd" 等)生成对应的代码

    ts
    class Calculator {
      add(x: number, y: number): number {
        return x + y;
      }
    }
    
    export = Calculator;
    ts
    class Calculator {
      add(x, y) {
        return x + y;
      }
    }
    module.exports = Calculator;
    ts
    define(['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 编译器选项,以下代码会报错:

ts
// 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?

可擦除 的类型语法指的是:当我们删除这些类型语法结构时,代码的运行时行为不会受到影响。

这是一个非常重要的概念,让我们通过一个简单的例子来理解。

普通的类型注解 就是 可擦除 的:

ts
// 带类型注解的代码
const name: string = 'TypeScript';

// 删除类型注解后的代码
const name = 'TypeScript';

可以看到,删除 : string 类型注解后,代码依然是合法的 javascript,而且运行结果完全相同。

函数参数的类型注解 也是如此:

ts
// 带参数类型的函数
function greet(name: string) {
  console.log(`Hello, ${name}!`);
}

// 删除参数类型后的函数
function greet(name) {
  console.log(`Hello, ${name}!`);
}

但是 枚举命名空间类参数属性 这些类型语法并不是 可擦除 的,因为这些类型语法在编译阶段会额外生成 javascript 代码:

ts
enum Example {
  foo
}

// typescript compiler will generate the following code
var Example;
(function (Example) {
  Example[(Example['foo'] = 0)] = 'foo';
})(Example || (Example = {}));
ts
// 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 = {}));
ts
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 模块,插件内部会使用到 typescriptcompiler api 来转译这些复杂的语法,生成额外的运行时代码。
  • esbuild 内置了对于 typescript 的转译工作,可以将复杂的 typescript 类型转换为 javascript 代码。

Why Is erasableSyntaxOnly Being Added?

引入 erasableSyntaxOnly 有几个重要原因:

Default support for Node.js

通过 type-stripping 文章的介绍中可知,node.jsv22.6.0 版本中添加了对 typescript 的类型移除的实验性支持,并在 v23.6.0 版本中默认启用类型剥离。

但是 node.js 默认只会执行那些不需要转换的 typescript 特性(即 可擦除 的类型)的模块。在这种模式下,node.js 会将内联的 类型注解 替换为 空白字符,并且不会执行任何类型检查。

Additional Feature Support

如果需要支持更复杂的 typescript 特性转换(即 不可擦除 的类型),可以使用 --experimental-transform-types 标志,这个特性在 node.jsv22.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:

ts
// 带类型注解的代码
const name: string = 'TypeScript';

// 删除类型注解后的代码
const name = 'TypeScript';

这将是生态系统的重大进步,离在浏览器中直接运行 typescript 更近了一步。但是,这种方案只适用于 可擦除语法。像 枚举命名空间 等需要复杂转换的特性就无法适配 类型即注释 特性。

What About Import Aliases?

导入别名(通过 paths 选项配置)是处理导入的一种流行方式。不幸的是,在使用 erasableSyntaxOnly 时这种方式是无法工作。

推荐的解决方案是依赖于非 typescript 方法来处理这个问题:package.json#importstypescriptnode.js 默认支持此功能。

json
{
  "imports": {
    "#dep": {
      "node": "dep-node-native",
      "default": "./dep-polyfill.js"
    }
  },
  "dependencies": {
    "dep-node-native": "^1.0.0"
  }
}

贡献者

页面历史

Discuss

根据 CC BY-SA 4.0 许可证发布。 (9037680)