Node.js 现在能直接跑 TypeScript 了?Type Stripping 原理、限制与 tsx / ts-node 取舍
2026/7/11 19:55:05 网站建设 项目流程

前言

“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.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 已经稳定且默认开启,但能跑的只是"可擦除"的 TypeScript 语法;曾经用来处理 enum 等语法的 transform-types 标志已经没了。

核心思路:Type Stripping 到底做了什么

Type Stripping 的原理可以用一句话概括:把 TypeScript 类型注解、接口、类型别名等"可擦除"语法替换成空白字符,然后把剩下的 JavaScript 直接交给 V8 执行。

注意三个关键词:

  1. 可擦除——只处理"删掉不影响运行逻辑"的语法(类型注解、interface、type 别名、import type)。
  2. 替换成空白——不是重新生成代码,而是用等长的空白替换被擦除的部分,这样行号不会变,堆栈跟踪能对上源码,也不需要 source map。
  3. 不做类型检查——Node 不会帮你检查类型错误,它只管把类型擦掉然后跑。

这和tsc的完整编译有本质区别。tsc做两件事:类型检查 + 语法转译(包括生成运行时代码)。Type Stripping 只做了"擦除"这一步中最轻量的部分。

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

判断一种 TypeScript 语法能不能被 Type Stripping 支持,核心标准是:擦掉它之后,剩下的 JavaScript 能不能直接跑?

  • 能擦除的(Node 原生支持):类型注解let x: number、接口interface、类型别名type、泛型<T>import typeas断言。
  • 不能擦除的(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

输出:

helloworld

Node 只是把: 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.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 不支持 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 处理enumnamespace、参数属性等需要转译的语法。很多教程和项目在 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:加构建步骤(推荐生产环境)

tscesbuildswc在部署前编译成 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/装饰器/JSXtsconfig 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,生产用tscesbuild构建后跑.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。

实际使用中记住三条主线:

  1. Node 原生 +tsc --noEmit是零运行时依赖的组合:Node 负责跑,tsc 负责查类型,配合erasableSyntaxOnly: true可以在检查阶段就拦截不可擦除语法。
  2. Node 26 移除了--experimental-transform-types,如果你的代码有 enum 并依赖这个标志,升级前必须迁移:加构建步骤、换 tsx,或重构 enum 为as const对象。
  3. 选型的核心问题是"你的代码里有没有 Node 擦不掉的语法"。没有就用原生,有就用 tsx 或构建工具,不要在生产环境依赖实验性标志。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询