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.0 | Type Stripping 默认开启,不再需要手动加标志 |
| v24.3.0 / v22.18.0 | Type Stripping 不再输出实验性警告 |
| v25.2.0 / v24.12.0 | Type 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 执行。
注意三个关键词:
- 可擦除(erasable)——只处理"删掉后不影响运行逻辑"的语法:类型注解、
interface、type别名、泛型参数、import type、as断言。 - 替换成空白(not regenerate)——它不是用另一个编译器重新生成代码,而是用等长的空白替换被擦除的部分。好处是行号不变,堆栈跟踪能和源码对上,因此不需要 source map。
- 不做类型检查(no type checking)——Node 不帮你验证类型对不对,它只负责把类型擦掉然后跑。类型错误会一路带到运行时。
2.2 可擦除 vs 需要转译:分界线在哪
判断一种 TypeScript 语法能不能被 Type Stripping 支持,只有一个标准:
擦掉它之后,剩下的 JavaScript 还能不能直接跑?
- 能擦除(Node 原生支持):类型注解
let x: number、接口interface、类型别名type、泛型、导入类型import type、as断言。 - 不能擦除(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 原生 | tsx | ts-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 的速度来自三件事:
- 单趟擦除:Amaro/SWC 一次遍历就把类型注解替换成空白,不做完整 AST 重写。
- 零类型检查:省掉了 tsc 最贵的那部分——全程序类型推导。
- 无产物落盘:直接内存中擦除后交给 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 / 脚本 / Serverless | Node 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 现在就能做的三件事
- 开启
isolatedModules+verbatimModuleSyntax:强制自己写"可擦除 TS",现在就为原生模式铺路。 - 用
import type和as const对象替代 enum:既符合原生模式,也更符合现代 TS 风格。 - 双轨跑起来:开发
node src/index.ts,CItsc --noEmit。丝滑与类型安全兼得。
Node.js 26 没有让 TypeScript "彻底不用编译",但它第一次让"跑 TS"这件事轻到了几乎没有存在感。当你能在终端里敲下 node app.ts 并立刻看到结果时,你会意识到——那个困扰了我们多年的"编译壳",正在被运行时一点点溶解掉。
对我们这代 Node 工程师来说,这或许就是 2026 年最值得记住的一个小里程碑:TypeScript 第一次,在 Node 里"裸奔"进了 V8。