Node.js 原生跑 .ts:类型剥离不是转译,Enum 直接报错,类型还得靠 tsc
以前跑一个 .ts 文件,得先选路线:tsc 先编译、ts-node 带类型检查、或者 tsx 这种 esbuild 封装。Node 从 v23.6(以及 v22.18 LTS)开始,默认就能直接 node app.ts 执行,不用先编译,也不用装运行时。背后机制是类型剥离(type stripping),和传统转译有本质区别,坑都埋在这。
类型剥离做了什么
Node 遇到 .ts 文件时,内部 loader(Amaro,基于 Rust 的 swc)把可擦除的 TypeScript 语法替换成空白,然后执行剩下的 JavaScript。
// 剥离前
const port: number = 3000;
// 剥离后
const port = 3000;
冒号和类型注解被删掉,但字符位置保留——这一行前后长度一致,堆栈里的行列号仍指向原文件,不需要 source map。这也是它和转译最关键的不同:没有产物文件、没有重排,只是就地挖掉类型。
哪些语法会直接报错
类型剥离只处理“删掉后不影响运行语义”的语法:类型注解、接口、类型别名、import type 这类都行。
需要生成 JS 代码的语法它不处理,默认直接报错:
enum(会生成反向映射对象)- 构造函数的参数属性(
constructor(private x: number)) - 带运行时代码的
namespace - 装饰器(decorator)
想跑这些,要么退回老工具,要么在 Node 里开转译模式 --experimental-transform-types(需生成 source map,性能开销大)。Node 26 起该 flag 已移除。.tsx 文件不支持——JSX 变换不在类型剥离范围内,是独立问题。
最大的坑:不做类型检查
node app.ts ≠ tsc app.ts。Node 只删类型,从不验证。给 number 变量赋 string,运行时照样执行,因为它看到的代码里已经没有类型这回事了。类型剥离替你免掉的是构建/转译这一步,不是校验这一步。类型安全得自己补:
- 运行时:
node app.ts直接跑 - 类型检查:
npx tsc --noEmit单独跑,CI 里必挂一道
tsconfig 怎么配
Node 运行时不读 tsconfig(忽略 paths、旧语法降级),但 tsconfig 决定 tsc 和编辑器会不会顺着 Node 行为走。官方推荐 TS 5.8+ 配这份:
{
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
erasableSyntaxOnly:让 tsc 在源码出现无法剥离的语法(enum、参数属性等)时报错——能通过 tsc 的代码,就一定能被 Node 直接跑。verbatimModuleSyntax:强制类型导入显式带type,配合剥离语义,避免import被当值导入、运行时抛错。rewriteRelativeImportExtensions:编译时把.ts后缀改写掉。编辑器与 tsc 检查 import 路径带.ts靠allowImportingTsExtensions。
只跑脚本不产出 JS 就用 noEmit: true;要分发 .js 就别开。
三个容易翻车的细节
import必须带.ts扩展名。ESM 下和 JS 一样强制全扩展名:import './file.ts',不是import './file'。require('./file.ts')同理。老 ts-node 项目迁移常在这挂。- 模块系统不自动转换。跑 ESM,就在最近父级
package.json加"type": "module"用import/export;要 CommonJS 用require/module.exports。.mts恒为 ESM,.cts恒为 CommonJS。 - 本地
import缺type会运行时炸。类型剥离靠type关键字识别“这只是类型导入”。不带type会被当值导入,最终运行时报错。verbatimModuleSyntax强制对齐。
适合什么、什么时候别用
类型剥离最顺手:构建脚本、CLI、内部工具、快速原型、后端 service 开发循环。node --watch server.ts 连 nodemon 都省,零依赖零配置:
{
"start": "node server.ts",
"dev": "node --watch server.ts",
"typecheck": "tsc --noEmit"
}
typecheck 单独占脚本位——它必须在 CI 里跑,运行时不兜底。
要发 npm 包给浏览器端、或重度依赖 enum/装饰器等需运行时转换特性,就别指望剥离:浏览器产物和 npm 包分发仍需真实构建。这类场景用 tsx 拿全量支持,npx tsx file.ts 或 node --import=tsx file.ts。