Node.js 现在能直接跑 TypeScript 了?Type Stripping 原理、限制与 tsx / ts-node 取舍 前言“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 262026 年 5 月发布直接移除了曾经能处理这些语法的--experimental-transform-types标志——没有替代方案。这篇文章就来把Node.js 原生跑 TypeScript这件事讲清楚Type Stripping 到底做了什么、不做什么哪些 TypeScript 语法会报错、为什么报错以及面对 Node 26 的变化你的项目到底该选 Node 原生、tsx、ts-node 还是老老实实加构建步骤。背景问题Node.js 原生 TypeScript 支持的时间线先理清版本脉络很多人搞混就是因为没分清两个标志和一个默认开启版本关键变化v22.6.02024-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 标记为稳定Stablev26.0.02026-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{returnHello,${user.name}! (ID:${user.id});}constalice:User{id:1,name:Alice,email:aliceexample.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 能正常处理。步骤 2Type Stripping 不做类型检查上面说了 Type Stripping 不检查类型。我们来验证一下——下面这段代码有明显的类型错误但 Node 照跑不误// no-check.tsfunctionadd(a:number,b:number):number{returnab;}// 类型标注是 number但传入 string —— tsc 会报错Node 不会constresultadd(hello,world);console.log(result);// 输出 helloworldnodeno-check.ts输出helloworldNode 只是把: number擦掉了然后执行function add(a, b) { return a b; }JavaScript 本身不做类型检查所以正常运行。这意味着如果你只用node app.ts跑代码而不加tsc --noEmit类型错误会一路带到运行时。后面会讲怎么把类型检查补上。步骤 3常见报错复现——enum、参数属性、装饰器enum 报错// enum-error.tsenumStatus{Activeactive,Pendingpending,Archivedarchived,}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(){returnIm${this.name};}}constunewUser(Alice,30);console.log(u.greet());nodeparam-prop-error.ts同样报ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX因为参数属性的public/private/readonly前缀需要转译成赋值语句。步骤 4import type 与路径扩展名的坑Type Stripping 有两个容易踩的行为差异从 tsx/ts-node 迁移过来时最容易遇到。import 必须带 type 关键字// types.tsexporttypeUserIDnumber;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:UserID42;constuser:ProfilecreateUser(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.tsexporttypeTaskStatuspending|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:pendingasTaskStatus};}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.tsERR_UNSUPPORTED_TYPESCRIPT_SYNTAX参数属性代码node param-prop-error.tsERR_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 不支持 TypeScriptType Stripping 不支持 Node REPL交互式命令行、--check标志和inspect调试器中的 TypeScript 语法。--eval和 stdin 可以通过--input-typemodule启用。Node 26 移除 transform-types影响与迁移这是 2026 年最需要关注的变化。Node.js 26.0.02026 年 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--importtsx src/index.ts方案 C重构 enum 为 const 对象如果你的项目不大直接把enum改成as const模式就能用 Node 原生 Type Stripping// 原来的 enumNode 原生不支持enumStatus{Activeactive,Pendingpending,}// 改成 const 对象 联合类型Node 原生支持constStatus{Active:active,Pending:pending,}asconst;typeStatus(typeofStatus)[keyoftypeofStatus];erasableSyntaxOnly: trueTS 5.8可以在tsc --noEmit阶段帮你找出所有需要重构的enum。选型决策Node 原生 vs tsx vs ts-node vs tsc 构建方案依赖enum/装饰器/JSXtsconfig paths类型检查适用场景Node 原生 Type Stripping无不支持不支持用 subpath imports需配 tsc --noEmit脚本、小工具、纯可擦除语法的项目tsx外部包支持支持需另配开发调试、需要完整 TS 语法的中小项目ts-node外部包支持支持需另配老项目维护新项目推荐 tsxtsc 构建 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)returntsc build node dist;// 用了 Node 原生不支持的语法if(opts.hasEnums||opts.hasDecorators||opts.hasJsx){returntsx (开发) tsc (生产构建);}// Node 22.18 原生就够if(opts.nodeVersion22.18){returnnode 原生 tsc --noEmit 类型检查;}// 低版本 Nodereturntsx;}总结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 或构建工具不要在生产环境依赖实验性标志。