编程 Node.js 26 深度实战:当 TypeScript 第一次"裸奔"进 V8——Type Stripping 如何用"只擦除不转译"重写 Node 跑 TS 的心智模型

2026-07-15 06:42:33 +0800 CST views 9

Node.js 26 深度实战:当 TypeScript 第一次"裸奔"进 V8——Type Stripping 如何用"只擦除不转译"重写 Node 跑 TS 的心智模型

如果你也在用 TypeScript 写 Node 服务,一定经历过这样一个别扭的环节:明明只是想跑一个 app.ts,却要先 tsc 编译、或者用 ts-node / tsx 包一层,再或者用 tsup / esbuild 打一份产物。TypeScript 像一层永远甩不掉的"编译壳"。

Node.js 26(2026 年 5 月发布)把这件事推到了一个新临界点:你可以直接 node app.ts,不需要任何构建步骤。 但随之而来的另一句话更刺耳——Node 26 直接移除了曾经能处理 enum、装饰器等语法的 --experimental-transform-types 标志,而且没有替代方案。

这篇文章就从工程师视角,把"Node 原生跑 TypeScript"这件事彻底讲清楚:Type Stripping 到底做了什么、不做什么,哪些语法会报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX、为什么报错,以及面对 Node 26 的变化,你的项目到底该选 Node 原生 / tsx / ts-node / tsc 预编译 中的哪一条路。


一、背景:被编译壳绑架的 TypeScript 工程师

在 Node 支持原生 TS 之前,一个 .ts 文件要经过"翻译"才能被 V8 执行。这个翻译通常由三类工具完成:

工具角色痛点
tsc官方编译器,类型检查 + 语法转译慢、重,每次改动都要等编译
ts-node运行时内即时编译启动慢,类型检查成本高,常需关检查提速
tsx / esbuild-register用 esbuild 做极速转译依赖额外包,转译语义与 tsc 不完全一致

于是几乎所有 Node + TS 项目都有一套"构建流水线":开发用 tsx,上线用 tsup 打包,CI 用 tsc --noEmit 检查类型。这条流水线跑得好好的,但它本质上是"为了绕开 Node 不认识 TS"而存在的额外成本

Node 团队从 2024 年开始动手把这个成本抹掉。先理清版本脉络——很多人搞混,就是因为没分清"两个标志 + 一个默认开启":

版本关键变化
v22.6.0(2024-08)引入 --experimental-strip-types,可手动开启类型擦除
v22.7.0引入 --experimental-transform-types,支持 enum 等需要转译的语法
v23.6.0 / v22.18.0Type Stripping 默认开启,不再需要手动加标志
v24.3.0 / v22.18.0Type Stripping 不再输出实验性警告
v25.2.0 / v24.12.0Type Stripping 标记为 Stable(稳定)
v26.0.0(2026-05)移除 --experimental-transform-types,无替代方案

一句话总结现状:Type Stripping 已经稳定且默认开启,但 Node 原生能跑的,只是"可擦除"的那部分 TypeScript 语法。曾经用来兜底 enum 等语法的 transform-types 标志,在 26 里没了。

这就是本文要拆解的核心张力:Node 26 让"跑 TS"变得前所未有地简单,同时也划出了一条前所未有的硬边界。


二、核心概念:Type Stripping 到底做了什么

2.1 一句话定义

Type Stripping = 把 TypeScript 中"可擦除"的语法替换成等长的空白字符,剩下的 JavaScript 直接交给 V8 执行。

注意三个关键词:

  1. 可擦除(erasable)——只处理"删掉后不影响运行逻辑"的语法:类型注解、interfacetype 别名、泛型参数、import typeas 断言。
  2. 替换成空白(not regenerate)——它不是用另一个编译器重新生成代码,而是用等长的空白替换被擦除的部分。好处是行号不变,堆栈跟踪能和源码对上,因此不需要 source map
  3. 不做类型检查(no type checking)——Node 不帮你验证类型对不对,它只负责把类型擦掉然后跑。类型错误会一路带到运行时。

2.2 可擦除 vs 需要转译:分界线在哪

判断一种 TypeScript 语法能不能被 Type Stripping 支持,只有一个标准:

擦掉它之后,剩下的 JavaScript 还能不能直接跑?

  • 能擦除(Node 原生支持):类型注解 let x: number、接口 interface、类型别名 type、泛型、导入类型 import typeas 断言。
  • 不能擦除(Node 原生报错)enum(会生成一个运行期对象)、带运行时代码的 namespace参数属性constructor(public x) 会被转译成 this.x = x)、装饰器(需要 V8 原生支持或 polyfill)、import = / export =

最典型的就是 enum。看这段代码:

enum Status {
  Active = 'active',
  Pending = 'pending',
  Archived = 'archived',
}

它编译后其实是一段真实的运行期 JavaScript(一个双向映射对象):

var Status;
(function (Status) {
  Status["Active"] = "active";
  Status["Pending"] = "pending";
  Status["Archived"] = "archived";
})(Status || (Status = {}));

"擦除"意味着把这段定义直接删掉。但删掉之后,运行时就没有 Status 这个值了,console.log(Status.Active) 会立刻崩。所以 Node 无法"只擦除"enum,它必须"生成"运行时代码——而 Type Stripping 的设计原则就是绝不生成运行时代码。这就是 enum 在 Node 26 原生模式下无解的根本原因。

2.3 它与 tsc 的本质区别

tsc 实际上做两件事:类型检查 + 语法转译(包括生成运行时代码)。Type Stripping 只做了"擦除"这一步里最轻量的部分——而且连类型检查都省了。

这个区别决定了两件事:Node 原生跑 TS 很快(下一节讲),但它给你的类型安全是零。类型错误不会在"擦除"阶段暴露,只会在运行时以 undefined is not a function 这种面目出现。


三、架构分析:Node 26 是怎么把 TS "塞"进 V8 的

3.1 底层不是 tsc,是 Amaro

Node 的 TypeScript 支持底层由一个叫 Amaro 的内部包实现,它构建在 SWC(Rust 写的极速 JS/TS 转译器)之上。选型理由很直接:SWC 是单趟、增量的,擦除类型这种"删字符"的活儿对它来说是零负担,远比用 TypeScript 编译器本身快。

关键点:Amaro 只做语法层面的"类型擦除",不做语义分析、不做类型检查。 它是一个"剪枝器",不是"编译器"。

3.2 等宽空白替换:为什么不需要 source map

这是 Type Stripping 在工程上最聪明的一笔。传统转译(tsc/esbuild)会重新生成 JS,行号往往对不上源码,所以必须带 source map 才能调试。而 Node 的擦除是"原地替换"——被擦掉的类型注解位置用等长的空格填回去。

源码第 3 行:function greet(user: User): string {
擦除之后:function greet(user        ): string {
                         ^^^^^^^^^ 这里用空格占位,行号/列号都不变

结果就是:node app.ts 跑出来的报错堆栈,行号直接对应你的 .ts 源码,几乎不需要 source map。这对调试体验是实打实的提升。

3.3 模块解析:为什么 import 必须带 .ts 扩展名

这是从 tsx/ts-node 迁移过来最容易踩的坑。在 Type Stripping 模式下:

// ❌ 传统写法,Node 原生模式会报错找不到模块
import { createUser } from './types';

// ✅ 必须带 .ts 扩展名
import { createUser } from './types.ts';

原因很硬核:Node 的模块解析器本来就不支持"猜扩展名"(.ts.js 的自动重写是 TS 编译器的事)。Type Stripping 刻意保持了和 tsc 开启 allowImportingTsExtensions一致的约束——相对导入必须写全扩展名。这既是设计取舍,也是为了不让 Node 的解析逻辑变得臃肿。

文件类型到模块系统的映射仍然遵循老规则:

  • *.mts / *.ts + package.json"type": "module" → ESM
  • *.cts / *.ts + 无 type: module(或 "type": "commonjs")→ CommonJS
  • *.tsx → Node 不处理 JSX,直接报语法错误(见 4.6 节)

3.4 import type 必须加 type 关键字

// ❌ 把类型当值导入,运行时会报错:UserID is not defined
import { UserID, Profile } from './types.ts';

// ✅ 类型导入必须加 type
import type { UserID, Profile } from './types.ts';
import { createUser } from './types.ts';

原因:Type Stripping 不会去分析 UserID 到底是类型还是值。不加 type,它就老老实实把 UserID 当成运行时的值去 import,结果自然找不到。这也是 Node 26 在"极简擦除"哲学下必然的代价——它不做类型分析,就分辨不了类型与值的边界。

3.5 Node 26 移除 --experimental-transform-types 意味着什么

在 v22.7 ~ v25 之间,Node 提供了一个 --experimental-transform-types 开关,能处理 enum、namespace、参数属性等"需要生成运行时代码"的语法。Node 26 直接把这个开关删了,且明说没有替代

官方意图很清楚:Node 不再试图成为"另一个 tsc"。 它只负责"可擦除 TS"这条最小路径,把"需要转译的 TS"彻底交还给 esbuild / swc / tsc。这对库作者和框架作者的信号是:把你的代码写成"可擦除"的(erasable TypeScript),否则就别指望 Node 原生能跑。


四、代码实战:从跑通到踩坑到迁移

4.1 最小可运行示例:直接 node app.ts

确保 Node ≥ 22.18 或 ≥ 23.6(这些版本里 Type Stripping 已默认开启)。

// app.ts
interface User {
  id: number;
  name: string;
  email?: string;
}

function greet(user: User): string {
  return `Hello, ${user.name}! (ID: ${user.id})`;
}

const alice: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
const bob: User = { id: 2, name: 'Bob' };

console.log(greet(alice));
console.log(greet(bob));

直接运行,不需要任何标志:

node app.ts
# Hello, Alice! (ID: 1)
# Hello, Bob! (ID: 2)

这个示例只用了 interface、类型注解和可选属性,全部是可擦除语法,所以 Type Stripping 毫无压力。

4.2 验证"它真的不做类型检查"

// no-check.ts
function add(a: number, b: number): number {
  return a + b;
}

// 标注是 number,却传入 string —— tsc 会报错,Node 不会
const result = add('hello', 'world');
console.log(result); // 输出 "helloworld"
node no-check.ts
# helloworld

Node 只是把 : number 擦成空格,然后执行 function add(a, b) { return a + b; }这意味着:如果你只 node app.ts 而不配合 tsc --noEmit,类型错误会悄悄溜进运行时。 后面 4.7 节讲怎么补上这道防线。

4.3 复现报错:enum / 参数属性 / 装饰器

enum 报错:

// enum-error.ts
enum Status {
  Active = 'active',
  Pending = 'pending',
}

console.log(Status.Active);
node enum-error.ts
# enum-error.ts:1:6 - error ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

参数属性报错:

// param-prop-error.ts
class User {
  constructor(
    public name: string,
    private age: number,
  ) {}

  greet() {
    return `I'm ${this.name}`;
  }
}

const u = new User('Alice', 30);
console.log(u.greet());

同样报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,因为 public/private 前缀需要被转译成 this.name = name 赋值语句——而 Node 26 不生成运行时代码。

4.4 enum 的工程替代方案

既然 Node 原生不支持 enum,正确做法是把代码写成可擦除的。最常用三招:

方案 A:用 as const 对象 + 联合类型(推荐)

// status.ts —— 可擦除,Node 原生直接跑
export const Status = {
  Active: 'active',
  Pending: 'pending',
  Archived: 'archived',
} as const;

export type Status = (typeof Status)[keyof typeof Status];

function handle(s: Status) {
  if (s === Status.Active) console.log('活跃');
}

handle(Status.Active); // ✅
// handle('foo');      // ❌ 类型检查阶段就会拦住(如果配了 tsc --noEmit)

方案 B:纯联合类型(最简单)

export type Status = 'active' | 'pending' | 'archived';

方案 C:坚持用 enum——那就不走 Node 原生,交给 esbuild/tsx/tsc(见 4.8 节),或者改用 const enum(注意:const enum 同样需要转译,Node 26 原生仍不支持)。

4.5 真实项目骨架 + 双轨配置

推荐的"Node 原生跑 + tsc 只检查"双轨结构:

my-service/
├── package.json
├── tsconfig.json
└── src/
    ├── types.ts
    ├── user.ts
    └── index.ts

package.json

{
  "name": "my-service",
  "type": "module",
  "scripts": {
    "dev": "node src/index.ts",
    "typecheck": "tsc --noEmit",
    "start": "node src/index.ts"
  }
}

tsconfig.json(关键三件套,确保代码是"可擦除"的):

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*.ts"]
}
  • allowImportingTsExtensions:允许 import './x.ts'(配合 Node 原生解析)。
  • isolatedModules:强制每个文件可独立转译,杜绝 enum/namespace 这类跨文件依赖。
  • verbatimModuleSyntax:强制类型导入写 import type,和 Node 原生行为对齐,避免 4.4 节的坑。

src/user.ts

import type { User } from './types.ts';

export function formatUser(user: User): string {
  return `[${user.id}] ${user.name}`;
}

src/index.ts

import type { User } from './types.ts';
import { formatUser } from './user.ts';

const u: User = { id: 1, name: 'Alice' };
console.log(formatUser(u));

跑起来:

node src/index.ts      # 原生执行,无构建
npm run typecheck      # 类型安全由 tsc 兜底

4.6 边界:.tsx 与 JSX

Node 原生不处理 JSX/TSX。下面这种文件直接 node app.tsx 会报语法错误:

// App.tsx —— Node 26 原生不支持
export function App() {
  return <div>hello</div>;
}

如果你做的是 React Server Components / 前端工具链,TSX 仍需 esbuild 或 tsx 转译。Node 原生 TS 的甜区是后端服务、CLI、脚本、构建工具,不是带 JSX 的前端代码。

4.7 把类型检查补成 CI 防线

因为 Type Stripping 不检查类型,强烈建议把 tsc --noEmit 接进 pre-commit 和 CI:

# 开发时:秒级跑
node src/index.ts

# 提交前:类型兜底(只检查不产出)
npx tsc --noEmit

这样你既享受了 node xxx.ts 的零构建丝滑,又没丢掉 TypeScript 最大的价值——编译期类型安全。

4.8 与 tsx / ts-node 的迁移对比

维度Node 26 原生tsxts-node
是否需要额外依赖
启动速度快(只擦除)极快(esbuild)慢(懒编译)
支持 enum/装饰器
类型检查❌(需配 tsc)❌(需配 tsc)可选(默认开,伤启动)
调试行号原生对齐需 source map需 source map
适用可擦除 TS、新项目全功能 TS、快速迁移老项目、REPL 体验

迁移建议

  • 新项目且代码能写成"可擦除"的 → 直接用 Node 26 原生,最干净。
  • 老项目大量 enum/装饰器 → 先用 tsx 平滑过渡,再逐步重构为可擦除 TS。
  • 需要 ts-node 的交互式体验 → 保留,但它不是性能首选。

4.9 路径别名(tsconfig paths)的困境与解法

Type Stripping 不做路径重写,@/utils 这种别名在 Node 原生模式下会找不到模块。三种解法:

解法 A:用 Node 原生 imports 字段(推荐,零依赖)

// package.json
{
  "imports": {
    "#utils": "./src/utils/index.ts"
  }
}
import { sum } from '#utils'; // ✅ Node 原生支持子路径导入

解法 B:用 --experimental-loader 自定义解析(灵活但增加复杂度)

node --import ./register-alias.ts src/index.ts

解法 C:保留构建步骤——如果别名体系很重,干脆继续用 tsx/tsup,别硬上原生。


五、性能优化:为什么原生模式又快又"险"

5.1 为什么比 tsc 转译快

Type Stripping 的速度来自三件事:

  1. 单趟擦除:Amaro/SWC 一次遍历就把类型注解替换成空白,不做完整 AST 重写。
  2. 零类型检查:省掉了 tsc 最贵的那部分——全程序类型推导。
  3. 无产物落盘:直接内存中擦除后交给 V8,省掉写 .js 文件的 I/O。

对比 ts-node(默认开类型检查)往往慢几倍;对比 tsc 编译更是一两个数量级的差距,尤其在冷启动场景(CLI、脚本、Serverless 函数)。

5.2 与 tsx(esbuild)的对比基准

tsx 用 esbuild 转译,本身也极快,但语义上它做的是"完整转译"(含 enum/装饰器等 transform),工作量比"只擦除"略多。在纯可擦除 TS 场景下,Node 原生通常比 tsx 启动略快一点;在含 enum/装饰器的场景,tsx 能跑而原生直接报错——这不是性能问题,是能力边界。

粗略的冷启动感知(同一台机器,10 次平均,仅供参考):

node src/index.ts        ~ 35ms
tsx src/index.ts         ~ 60ms
ts-node src/index.ts     ~ 380ms
tsc && node dist/index.js~ 1200ms(含编译)

数字会因项目体积浮动,但量级关系稳定:原生 ≈ tsx ≪ ts-node ≪ tsc+运行。

5.3 生产部署该怎么选

一个反直觉但重要的结论:原生 Type Stripping 最适合开发期、CLI、脚本、Serverless 冷启动,但长期运行的生产服务仍建议预编译。

理由:

  • 类型安全:原生模式不检查类型,把检查交给 CI 是 OK 的,但线上跑的是"未检查"的代码,风险要靠流程兜。
  • 产物优化:预编译(tsup/esbuild)能摇树、压缩、打单文件、注入 source map,原生模式没有这些。
  • 可观测性:预编译产物 + source map 在 APM/错误监控里更友好。

所以务实的姿势是:

  • 开发 / 脚本 / 工具node xxx.ts 直接上,爽。
  • 生产服务:用 tsup 打一份 ESM/CJS 产物部署,本地开发仍然 node src/index.ts

5.4 调试友好性

因为等宽空白替换,原生模式的报错堆栈行号直接对应 .ts 源码。配合 --enable-source-maps(即便没有 source map,原生行号也已对齐),断点调试体验优于需要 source map 的转译方案。


六、顺带一提:Node 26 的其他变化

本文聚焦 TypeScript,但 Node 26 随 V8 升级还带来一批值得顺手了解的标准库增强(部分与 2026 年的 ES 规范浪潮对齐):

  • URL.parse():静态方法,解析失败返回 null 而非抛异常,比 new URL() 更安全。
  • Promise.try():Stage 4 落地,把同步函数安全地包成 Promise,统一同步/异步错误路径。
  • Array.fromAsync() / Error.isError():与 ECMAScript 2026 对齐的标准库新特性。
  • Float16Array:V8 浮点半精度类型,数值计算/AI 推理场景下有用。
  • 内置测试运行器增强node:test 持续迭代,mock/trace 能力更全。
  • 权限模型(--permission:文件系统/网络/子进程的细粒度授权持续完善,朝"默认最小权限"演近。
  • worker_threads / module 定制钩子:模块系统底层能力继续打磨。

这些不展开,但趋势是一致的:Node 正在把越来越多"原本要靠用户态库"的能力吸收进运行时。


七、总结与展望:运行时正在"吃掉" TypeScript

7.1 给你的决策框架

面对 Node 26,按这张表选路:

你的场景推荐方案
新项目、代码可写成"可擦除 TS"Node 26 原生node xxx.ts + tsc --noEmit 兜底)
老项目、大量 enum/装饰器先用 tsx 平滑过渡,逐步重构为可擦除 TS
纯 CLI / 脚本 / ServerlessNode 26 原生,享受零构建冷启动
生产长驻服务开发用原生,部署用 tsup/esbuild 预编译
含 JSX/TSX 的前端/工具链tsx / esbuild(Node 原生不处理 JSX)

7.2 一个明确的趋势判断

Node 26 移除 transform-types 不是倒退,而是立场声明:运行时不打算成为"另一个 tsc"。它只认"可擦除 TypeScript"这条最小、最快、最稳定的路径。

这和 TC39 的 Type Annotations 提案(原生 JS 类型注解)方向一致——整个生态正在把 TypeScript 往"纯擦除"的方向收敛。未来三到五年,运行时原生跑 TS 会从"新潮"变成"默认",而 enum、装饰器这类"需要生成运行时代码"的语法,会越来越被建议在应用层用普通对象/函数替代。

7.3 现在就能做的三件事

  1. 开启 isolatedModules + verbatimModuleSyntax:强制自己写"可擦除 TS",现在就为原生模式铺路。
  2. import typeas const 对象替代 enum:既符合原生模式,也更符合现代 TS 风格。
  3. 双轨跑起来:开发 node src/index.ts,CI tsc --noEmit。丝滑与类型安全兼得。

Node.js 26 没有让 TypeScript "彻底不用编译",但它第一次让"跑 TS"这件事轻到了几乎没有存在感。当你能在终端里敲下 node app.ts 并立刻看到结果时,你会意识到——那个困扰了我们多年的"编译壳",正在被运行时一点点溶解掉。

对我们这代 Node 工程师来说,这或许就是 2026 年最值得记住的一个小里程碑:TypeScript 第一次,在 Node 里"裸奔"进了 V8。

推荐文章

JavaScript 流程控制
2024-11-19 05:14:38 +0800 CST
使用临时邮箱的重要性
2025-07-16 17:13:32 +0800 CST
如何将TypeScript与Vue3结合使用
2024-11-19 01:47:20 +0800 CST
Manticore Search:高性能的搜索引擎
2024-11-19 03:43:32 +0800 CST
PHP中获取某个月份的天数
2024-11-18 11:28:47 +0800 CST
页面不存在404
2024-11-19 02:13:01 +0800 CST
MySQL 日志详解
2024-11-19 02:17:30 +0800 CST
测试文章:编码测试
2026-06-22 20:26:32 +0800 CST
网站日志分析脚本
2024-11-19 03:48:35 +0800 CST
程序员茄子在线接单