Skip to content

每日一报

proposal-type-annotations

Ref

proposal-type-annotations - Mar 29 2022

  • 2000.07: 最早的 javascript 类型提案之一来自 Waldemar Horwat
  • 2008: ECMAScript 4 草案中曾包含类型和可选类型注解,但后来被撤回。
  • 2015: GoogleV8 团队曾尝试实现 Strong Mode,但因 实验失败 而取消了该实验。
  • 2020-2023: 根据 State of JS 调查,静态类型连续多年成为 javascript 开发者最需要的特性。
    • 2022.03.29: 讨论了 Types as CommentsStage 1 提案。
    • 2022.03.31: 继续讨论这个主题。
    • 2023.03.22: 在 TC39 会议上进行了类型注解提案的更新讨论。

这个提案的核心目标是让开发者可以在 javascript 代码中直接添加 类型注解,而这些注解会被 javascript 引擎 当作注释忽略。这意味着使用 typescriptflow 等类型系统的开发者可以在不需要转译的情况下直接运行代码。

Main Content Of The Proposal

  1. 类型注解语法

    提案支持多种类型注解形式:

    • 变量和参数的类型注解: let x: string
    • 函数返回值类型: function foo(): number
    • 类成员的类型注解: class Person { name: string }
    • 接口定义: interface Person { name: string }
    • 类型别名: type ID = string
    • 泛型语法: Array<string>
  2. 类型导入导出

    提案允许使用专门的语法来 导入导出类型:

    ts
    import type { Person } from './types';
    export type { Person };

    这些语句在 运行时 会被完全忽略。

  3. 实现策略

    提案采用 "类型即注释" 的策略 - 所有的类型相关语法在运行时都会被 javascript 引擎当作注释处理。这种方式的优点是:

    • 不需要在运行时进行类型检查,避免性能开销。
    • 允许类型系统(如typescriptflow)继续独立发展保持 javascript 的向后兼容性。
  4. 有意识的省略

    提案明确排除了一些 typescript 特性,因为这些特性会生成实际的 javascript 代码:

    • 枚举(enums)
    • 命名空间(namespaces)
    • 参数属性(parameter properties)
    • JSX 语法
  5. 讨论中的特性

    还有一些特性正在讨论中:

    • 环境声明 (ambient declarations)
    • 函数重载语法
    • 类和字段修饰符(如 publicprivate等)

The Importance Of Proposals

体现以下几个方面:

  1. 解决生态系统分裂问题

    目前 javascript 的类型系统(如 typescriptflow)都需要自己的编译器,这导致了生态系统的分裂。这个提案试图提供一个标准的语法空间。

  2. 简化开发流程

    通过将类型注解标准化为注释,开发者可以在不需要编译步骤的情况下直接运行带有类型的代码,这对于开发效率有很大提升。

  3. 保持创新空间

    通过将类型检查留给外部工具,而不是在语言层面定义类型系统,这个提案给予了类型系统创新的空间。

  4. 满足社区需求

    根据 State of JS 调查,静态类型一直是 javascript 开发者最需要的特性之一。这个提案直接回应了这个需求。

值得注意的是,这个提案并不是要替代 typescript 或其他类型系统,而是要为它们提供一个标准的语法基础。typescript 等工具仍然可以在此基础上提供更多高级特性。

目前这个提案处于 Stage 1 阶段,还有许多细节需要讨论和完善。

The Steps To Confirm Syntax Standard By TC39

一个提案需要经过以下五个阶段

  1. Stage 0 (Strawman)
  2. Stage 1 (Proposal)
  3. Stage 2 (Draft)
  4. Stage 3 (Candidate)
  5. Stage 4 (Finished)

才能被正式纳入 ECMAScript 标准。

但它代表了 javascript 在类型系统方面的一个重要发展方向,试图在保持语言 简单性 的同时,为 类型检查 提供更好的支持。

erasableSyntaxOnly

typescript 将在 v5.8 版本中引入了一个新的编译器标志 erasableSyntaxOnly。这个标志的核心目的是为了限制开发者仅允许使用 可擦除 的语法特性。当启用这个标志时,以下三种不可擦除 的类型语法将会被标记为错误:

  1. 枚举(enums)
  2. 命名空间(namespaces)
  3. 类参数属性(class parameter properties)

例如,以下代码在启用 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 来转译这些复杂的语法,生成额外的 javascript 代码。
  • esbuild 自身内置实现了 typescript 的类型系统,可以将复杂的 typescript 类型转换为 javascript 代码。

Why Is erasableSyntaxOnly Being Added?

引入 erasableSyntaxOnly 有几个重要原因:

Default support for Node.js

近期 node.jsv22.6.0 版本中添加了对 typescript 的类型移除的实验性支持,详情可查看 typescripttype-stripping 文档。通过使用 --experimental-strip-types 标志,node.js 现在能够直接运行 typescript 模块。

但是 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 语法转换为较旧标准

Attention

需要注意的是,某些依赖于 tsconfig.json 配置的 typescript 特性是特意不被支持的,例如:

  • 路径映射功能tsconfig 配置文件中的 paths 字段不会被转换,因此会产生错误。最接近的可用特性是子路径导入,但有一个限制:路径需要以 # 开头。
  • 将新版本的 javascript 语法转换为旧版本的功能。

因此若需要完整的 typescript 支持,建议参考完整的 typescript 支持文档

由于类型移除的特性,type 关键字对于正确移除类型导入是必需的。如果没有 type 关键字,node.js 会将导入视为值导入,这将导致运行时错误。可以使用 tsconfig 选项 verbatimModuleSyntax 来匹配这种行为。

以下示例将正确工作:

ts
import type { Type1, Type2 } from './module.ts';
import { fn, type FnParams } from './fn.ts';

以下示例将导致运行时错误:

ts
import { Type1, Type2 } from './module.ts';
import { fn, FnParams } from './fn.ts';

node.js 支持类型移除特性的设计初衷是保持轻量级,通过以下两个核心策略实现这一目标:

  • 有意不支持需要额外生成 javascript 代码的类型语法。
  • 将内联类型语法简单替换为空白字符。

由于内联类型被空白字符替换,因此源映射对于堆栈跟踪中的正确行号来说是不必要的;node.js 也不会生成它们。当启用 --experimental-transform-types 时,源映射默认开启。

可擦除语法 类型特性代表了 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"
  }
}

Feature Launch Time

这个特性将在不久的将来推出 typescript v5.8 版本,可以在 typescript playground 中提前尝试,对于非可擦除语法,会提示如下报错信息。

This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

SomeThing About typescript

Merge the .d.ts modules using tsc

typescripttsc 命令本身就可以通过一些配置实现类型声明文件的合并。

主要有两种方式

  1. 使用 outFile 选项

    json
    {
      "compilerOptions": {
        "declaration": true, // 生成声明文件
        "module": "AMD", // 必须是 AMD 或 System
        "outFile": "dist/index.js" // 合并输出
      }
    }

    除了 moduleNoneSystemAMD 之外的场景不能使用 outFile 来做处理。换句话说,outFile 不能用于捆绑 CommonJSES6 模块。

    这个限制主要是由于这上述模块系统的设计特性决定的 - 即上述符合要求的模块系统支持模块串行加载和依赖管理方式适合将所有的 .d.ts 模块合并到单个模块中。而其他不符合要求的模块系统(例如 CommonJSES6) 模块系统由于它们的动态导入特性和模块解析机制,不适合直接合并到单个文件中,所以 typescript 编译器不支持将后者模块系统与 outFile 一起使用。

  2. 使用 declarationDir 和 引用路径

    json
    {
      "compilerOptions": {
        "declaration": true, // 生成声明文件
        "declarationDir": "./types", // 声明文件输出目录
        "declarationMap": true, // 生成声明文件的 source map
        "module": "ESNext" // 支持所有模块类型
      }
    }

    然后创建一个入口声明文件:

    ts
    export * from './components';
    export * from './utils';
    export * from './hooks';

    这种方式更灵活、支持所有模块类型,但需要手动管理导出,同时文件是分散的,需要通过引用组织在一起。

    同时还需要注意的是,tsc 默认保留原始导入路径,并不会执行路径重写工作。因此若模块中依赖了路径别名(paths),那么需要手动处理。esbuild 会在构建阶段尊重 tsconfig.json 配置文件的 paths 字段,并执行路径重写工作。

总的来说,如果要通过纯 tsc 实现真正的单文件合并,受限于其模块系统的要求,实用性不如使用专门的打包工具(如在 rollup 中使用 rollup-plugin-dts 插件来实现 .d.ts 模块的打包)。这也是为什么在实际项目中,我们通常会选择使用额外的工具来处理类型声明文件的打包。

TS4023 Error

问题报错信息如下:

Exported variable [variableName] has or is using name [variableName] from external module [modulePath] but cannot be named.

问题复现路径:

ts
import { E } from './e';

export const mainMeta = {
  name: 'main',
  children: [E]
};
ts
enum EEnum {
  E = 'e'
}

export const E = EEnum.E;
json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Node",
    "baseUrl": ".",
    "declaration": true,
    "declarationDir": "dist/types",
    "emitDeclarationOnly": true
  },
  "include": ["src/**/*.ts"]
}

当执行 tsc 命令时,会报错:

bash
demo/main.ts:3:14 - error TS4023: Exported variable 'mainMeta' has or is using name 'EEnum' from external module "/Users/Project/tsc-error/src/e" but cannot be named.

3 export const mainMeta = {
               ~~~~~~~~


Found 1 error.

可以通过 export type 来解决这个问题:

ts
enum EEnum { 
export enum EEnum { 
  E = 'e'
}

export const E = EEnum.E;

修改后执行 tsc 成功,产物如下:

ts
export declare const mainMeta: {
  name: string;
  // eslint-disable-next-line
  children: import('./e').EEnum[];
};
ts
export declare enum EEnum {
  E = 'e'
}
export declare const E = EEnum.E;

在上述例子中,mainMetachildren 属性引用了 e 模块中的 EEnum 类型,但由于 e 模块中的 EEnum 没有被导出,成为了 e 模块的私有类型。typescript 无法在 main.d.ts 声明文件中正确地引用 EEnum 类型,从而导致了这个错误。

可以看出 typescript 在生成声明文件时需要能够命名和引用所有出现在公共 api 中的类型。如果类型没有被导出,或者存在循环依赖,typescript 就无法创建正确的类型引用,从而导致这个错误。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)