前言
“Node.js 不是已经原生支持 TypeScript 了吗?为什么我的enum还是报错?”
这是最近在技术社区高频出现的一类问题。从 Node.js 22.6 引入--experimental-strip-types开始,“Node 原生跑 TypeScript” 的消息就在各种教程和视频里传播开来。很多人跟着配了node app.ts,简单场景确实跑通了,觉得 Node 终于不用编译就能跑 TypeScript 了。
但一旦项目里用上enum、装饰器、路径别名,或者把.tsx文件丢进去,就会撞上ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX。更关键的是,Node.js 26(2026 年 5 月发布)直接移除了曾经能处理这些语法的--experimental-transform-types标志——没有替代方案。
这篇文章就来把"Node.js 原生跑 TypeScript"这件事讲清楚:Type Stripping 到底做了什么、不做什么,哪些 TypeScript 语法会报错、为什么报错,以及面对 Node 26 的变化,你的项目到底该选 Node 原生、tsx、ts-node 还是老老实实加构建步骤。
背景问题:Node.js 原生 TypeScript 支持的时间线
先理清版本脉络,很多人搞混就是因为没分清两个标志和一个"默认开启":
| 版本 | 关键变化 |
|---|---|
| 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 已经稳定且默认开启,但能跑的只是"可擦除"的 TypeScript 语法;曾经用来处理 enum 等语法的 transform-types 标志已经没了。
核心思路:Type Stripping 到底做了什么
Type Stripping 的原理可以用一句话概括:把 TypeScript 类型注解、接口、类型别名等"可擦除"语法替换成空白字符,然后把剩下的 JavaScript 直接交给 V8 执行。
注意三个关键词:
- 可擦除——只处理"删掉不影响运行逻辑"的语法(类型注解、interface、type 别名、
import type)。 - 替换成空白——不是重新生成代码,而是用等长的空白替换被擦除的部分,这样行号不会变,堆栈跟踪能对上源码,也不需要 source map。
- 不做类型检查——Node 不会帮你检查类型错误,它只管把类型擦掉然后跑。
这和tsc的完整编译有本质区别。tsc做两件事:类型检查 + 语法转译(包括生成运行时代码)。Type Stripping 只做了"擦除"这一步中最轻量的部分。
可擦除 vs 需要转译:分界线在哪
判断一种 TypeScript 语法能不能被 Type Stripping 支持,核心标准是:擦掉它之后,剩下的 JavaScript 能不能直接跑?
- 能擦除的(Node 原生支持):类型注解
let x: number、接口interface、类型别名type、泛型<T>、import type、as断言。 - 不能擦除的(Node 原生报错):
enum(会生成一个对象)、带运行时代码的namespace、参数属性(constructor(public x: number)会被转译成this.x = x)、装饰器(需要 V8 原生支持或 polyfill)。
为什么enum不能擦除?因为enum Status { Active = 'active' }在编译后会变成一段真实的 JavaScript 运行时代码(一个双向映射对象)。擦掉它,运行时就没有Status这个值了,所以 Node 无法处理。
实现步骤
步骤 1:最小示例——node app.ts 直接运行
确保你的 Node.js 版本 ≥ 22.18 或 ≥ 23.6(在这些版本中 Type Stripping 默认开启)。
// app.tsinterfaceUser{id:number;name:string;email?:string;}functiongreet(user:User):string{return`Hello,${user.name}! (ID:${user.id})`;}constalice:User={id:1,name:'Alice',email:'alice@example.com'};constbob:User={id:2,name:'Bob'};console.log(greet(alice));console.log(greet(bob));直接运行,不需要任何标志:
nodeapp.ts输出:
Hello, Alice! (ID: 1) Hello, Bob! (ID: 2)这个示例只用了 interface、类型注解和可选属性,全部是可擦除语法,所以 Type Stripping 能正常处理。
步骤 2:Type Stripping 不做类型检查
上面说了 Type Stripping 不检查类型。我们来验证一下——下面这段代码有明显的类型错误,但 Node 照跑不误:
// no-check.tsfunctionadd(a:number,b:number):number{returna+b;}// 类型标注是 number,但传入 string —— tsc 会报错,Node 不会constresult=add('hello','world');console.log(result);// 输出 "helloworld"nodeno-check.ts输出:
helloworldNode 只是把: number擦掉了,然后执行function add(a, b) { return a + b; },JavaScript 本身不做类型检查,所以正常运行。
这意味着:如果你只用node app.ts跑代码而不加tsc --noEmit,类型错误会一路带到运行时。后面会讲怎么把类型检查补上。
步骤 3:常见报错复现——enum、参数属性、装饰器
enum 报错
// enum-error.tsenumStatus{Active='active',Pending='pending',Archived='archived',}console.log(Status.Active);nodeenum-error.ts报错:
enum-error.ts:1:6 - error ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX原因前面讲过:enum需要生成运行时代码,Type Stripping 只擦除不生成。
参数属性报错
// param-prop-error.tsclassUser{// 参数属性语法:constructor(public name, private age)// tsc 会转译成 this.name = name; this.age = age;constructor(publicname:string,privateage:number,){}greet(){return`I'm${this.name}`;}}constu=newUser('Alice',30);console.log(u.greet());nodeparam-prop-error.ts同样报ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,因为参数属性的public/private/readonly前缀需要转译成赋值语句。
步骤 4:import type 与路径扩展名的坑
Type Stripping 有两个容易踩的行为差异,从 tsx/ts-node 迁移过来时最容易遇到。
import 必须带 type 关键字
// types.tsexporttypeUserID=number;exportinterfaceProfile{name:string;age:number;}exportfunctioncreateUser(id:number):Profile{return{name:'Anonymous',age:0};}// main.ts —— 正确写法:类型导入必须加 type 关键字importtype{UserID,Profile}from'./types.ts';import{createUser}from'./types.ts';constid:UserID=42;constuser:Profile=createUser(id);console.log(user);// main-broken.ts —— 错误写法:类型当值导入,运行时报错import{UserID,Profile}from'./types.ts';// 运行时报错!console.log(UserID);// ERR: UserID is not defined因为 Type Stripping 不会去分析UserID到底是类型还是值,不加type关键字它会当成值导入,运行时就找不到。
import 路径必须带文件扩展名
// ❌ 传统写法,Node 原生模式报错import{createUser}from'./types';// ✅ 必须带 .ts 扩展名import{createUser}from'./types.ts';Node.js 原生 ESM 规范要求 import 必须带完整扩展名,Type Stripping 也不会帮你自动补全。
步骤 5:用 tsconfig.json 配合 tsc --noEmit 补上类型检查
既然 Node 不做类型检查,标准做法是:Node 负责跑,tsc 负责查。
在项目根目录创建tsconfig.json(注意:Node 不读这个文件,它是给tsc用的):
{"compilerOptions":{"noEmit":true,"target":"esnext","module":"nodenext","strict":true,"rewriteRelativeImportExtensions":true,"erasableSyntaxOnly":true,"verbatimModuleSyntax":true,"allowImportingTsExtensions":true}}逐项解释:
noEmit: true——只做类型检查,不输出.js文件(Node 自己会跑.ts)。erasableSyntaxOnly: true——TS 5.8+ 新增,一旦你写了enum、参数属性等不可擦除语法,tsc会在检查阶段就报错提醒你,而不是等到 Node 运行时才炸。这是用 Node 原生 Type Stripping 时最重要的防线。verbatimModuleSyntax: true——强制要求类型导入必须用import type,和 Node 的行为一致。rewriteRelativeImportExtensions: true——TS 5.7+ 新增,如果你之后需要用tsc构建发布.js,它会把.ts导入路径重写成.js。allowImportingTsExtensions: true——允许 import 语句带.ts扩展名。
然后在package.json中配置脚本:
{"name":"my-typescript-app","type":"module","scripts":{"typecheck":"tsc --noEmit","start":"node src/index.ts","dev":"node --watch src/index.ts"}}工作流变成:
# 开发时:边写边检查类型npmrun typecheck# 类型检查(不输出文件)npmrun dev# 直接跑 .ts(带热重载)# 生产时:node src/index.ts 直接跑代码示例:完整的可运行项目
把上面的步骤组合成一个完整示例:
my-app/ ├── package.json ├── tsconfig.json └── src/ ├── types.ts ├── utils.ts └── index.ts// src/types.tsexporttypeTaskStatus='pending'|'running'|'done'|'failed';exportinterfaceTask{id:number;title:string;status:TaskStatus;}// 注意:不能用 enum,要用 const 对象 + 联合类型替代constPriority={Low:'low',Medium:'medium',High:'high',}asconst;typePriority=(typeofPriority)[keyoftypeofPriority];export{Priority};// src/utils.tsimporttype{Task,TaskStatus}from'./types.ts';exportfunctioncreateTask(id:number,title:string):Task{return{id,title,status:'pending'asTaskStatus};}exportfunctionformatTask(task:Task):string{return`[${task.status.toUpperCase()}] #${task.id}${task.title}`;}// src/index.tsimport{createTask,formatTask}from'./utils.ts';import{Priority}from'./types.ts';consttasks=[createTask(1,'Setup project'),createTask(2,'Write tests'),createTask(3,'Deploy'),];for(consttaskoftasks){console.log(formatTask(task));}console.log('Default priority:',Priority.Medium);运行:
npmrun typecheck# 先检查类型npmstart# 再运行输出:
[PENDING] #1 Setup project [PENDING] #2 Write tests [PENDING] #3 Deploy Default priority: medium注意这个项目全程没有enum,用了as const对象 + 联合类型替代,所以 Node 原生 Type Stripping 完全够用。
运行结果与效果说明
| 操作 | 命令 | 结果 |
|---|---|---|
| 直接运行纯类型注解代码 | node app.ts | 正常运行,无需任何标志 |
| 类型错误代码 | node no-check.ts | 正常运行(不检查类型),输出错误结果 |
| enum 代码 | node enum-error.ts | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX |
| 参数属性代码 | node param-prop-error.ts | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX |
| 类型检查 | tsc --noEmit | 检查所有类型错误,不输出文件 |
| 热重载开发 | node --watch src/index.ts | 文件改动自动重启 |
常见问题与避坑
1. 路径别名(paths)不生效
tsconfig.json里的paths(如@/*映射到src/*)Node 完全不读。Node 不解析tsconfig.json。
替代方案:使用 Node.js 原生的 subpath imports,在package.json中配置:
{"imports":{"#*":"./src/*"}}然后代码中:
import{createTask}from'#utils.ts';subpath imports 必须以#开头,这是 Node.js 原生支持的特性,不依赖 tsconfig。
2. .tsx 文件不支持
.tsx文件(JSX + TypeScript)完全不被支持,Node 会直接报错。因为 JSX 的处理依赖于具体框架(React、Solid、Preact 各有不同),Node 无法统一处理。
如果你的项目有.tsx文件,必须使用 tsx 或构建工具。
3. 从 tsx 迁移过来,import 路径报错
tsx / ts-node 会自动帮你补全.ts扩展名、处理paths别名、支持enum。迁移到 Node 原生模式时,这些都会成为报错点。迁移清单:
- 所有
import './foo'改成import './foo.ts' - 所有
paths别名改成 subpath imports - 所有
enum改成as const对象或加构建步骤 - 所有类型导入加
type关键字
4. node_modules 里的 TypeScript 文件不会被处理
Node.js 有意拒绝处理node_modules路径下的 TypeScript 文件,防止包作者直接发布.ts源码。如果你依赖的包只提供了.ts入口,Node 原生模式跑不了,需要 tsx 或构建步骤。
5. REPL 和 --check 不支持 TypeScript
Type Stripping 不支持 Node REPL(交互式命令行)、--check标志和inspect调试器中的 TypeScript 语法。--eval和 stdin 可以通过--input-type=module启用。
Node 26 移除 transform-types:影响与迁移
这是 2026 年最需要关注的变化。Node.js 26.0.0(2026 年 5 月)移除了--experimental-transform-types标志,没有提供替代方案。
之前这个标志做什么
在 Node 22.7 到 25 期间,--experimental-transform-types可以让 Node 处理enum、namespace、参数属性等需要转译的语法。很多教程和项目在 2024-2025 年间采用它作为"Node 原生跑完整 TypeScript"的方案。
谁会受影响
如果你的项目中存在以下任一情况,升级到 Node 26 后会出问题:
- Dockerfile / 部署脚本 / CI 配置中有
--experimental-transform-types - 代码中使用了
enum并依赖这个标志直接运行 - 运行在自动升级 Node 版本的托管平台(如 Vercel、Lambda),平台切到 Node 26 LTS 后会自动中断
报错信息和普通的 Type Stripping 报错一样:ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,指向你的enum声明行。
迁移方案(三选一)
方案 A:加构建步骤(推荐生产环境)
用tsc、esbuild或swc在部署前编译成 JavaScript,然后运行.js。这是最稳定的方案,适用所有 Node 版本:
{"scripts":{"build":"tsc","start":"node dist/index.js"}}方案 B:用 tsx 作为运行时
tsx 基于 esbuild,支持完整 TypeScript 语法(包括enum),且适用于所有 Node 版本:
npx tsx src/index.ts# 或node--import=tsx src/index.ts方案 C:重构 enum 为 const 对象
如果你的项目不大,直接把enum改成as const模式,就能用 Node 原生 Type Stripping:
// 原来的 enum(Node 原生不支持)enumStatus{Active='active',Pending='pending',}// 改成 const 对象 + 联合类型(Node 原生支持)constStatus={Active:'active',Pending:'pending',}asconst;typeStatus=(typeofStatus)[keyoftypeofStatus];erasableSyntaxOnly: true(TS 5.8+)可以在tsc --noEmit阶段帮你找出所有需要重构的enum。
选型决策:Node 原生 vs tsx vs ts-node vs tsc 构建
| 方案 | 依赖 | enum/装饰器/JSX | tsconfig paths | 类型检查 | 适用场景 |
|---|---|---|---|---|---|
| Node 原生 Type Stripping | 无 | 不支持 | 不支持(用 subpath imports) | 需配 tsc --noEmit | 脚本、小工具、纯可擦除语法的项目 |
| tsx | 外部包 | 支持 | 支持 | 需另配 | 开发调试、需要完整 TS 语法的中小项目 |
| ts-node | 外部包 | 支持 | 支持 | 需另配 | 老项目维护(新项目推荐 tsx) |
| tsc 构建 + node dist | 无额外运行时 | 支持 | 支持 | 构建时自带 | 生产部署、需要发布 .js 的项目 |
选型建议
- 写脚本、CLI 工具、一次性任务:直接
node script.ts,最轻量。 - 中小型后端项目,不用 enum 和装饰器:Node 原生 +
tsc --noEmit类型检查,零运行时依赖。 - 项目里大量使用 enum、装饰器、JSX:开发用
tsx,生产用tsc或esbuild构建后跑.js。 - 需要发布到 npm 或部署到只认 .js 的平台:必须加构建步骤,输出
.js。
一个实用的决策函数:
functionchooseRunner(opts:{hasEnums:boolean;hasDecorators:boolean;hasJsx:boolean;needsBuild:boolean;nodeVersion:number;}):string{// 需要构建发布的,直接 tscif(opts.needsBuild)return'tsc build + node dist';// 用了 Node 原生不支持的语法if(opts.hasEnums||opts.hasDecorators||opts.hasJsx){return'tsx (开发) + tsc (生产构建)';}// Node 22.18+ 原生就够if(opts.nodeVersion>=22.18){return'node 原生 + tsc --noEmit 类型检查';}// 低版本 Nodereturn'tsx';}总结
Node.js 原生 Type Stripping 是一个实用但被广泛误解的功能。它做的事情很简单:把可擦除的类型语法替换成空白,然后执行剩下的 JavaScript。它不是"内置 tsc",不做类型检查,不读 tsconfig,不支持 enum、装饰器、参数属性和 JSX。
实际使用中记住三条主线:
- Node 原生 +
tsc --noEmit是零运行时依赖的组合:Node 负责跑,tsc 负责查类型,配合erasableSyntaxOnly: true可以在检查阶段就拦截不可擦除语法。 - Node 26 移除了
--experimental-transform-types,如果你的代码有 enum 并依赖这个标志,升级前必须迁移:加构建步骤、换 tsx,或重构 enum 为as const对象。 - 选型的核心问题是"你的代码里有没有 Node 擦不掉的语法"。没有就用原生,有就用 tsx 或构建工具,不要在生产环境依赖实验性标志。