Node.js 原生 TypeScript 支持究竟能做什么、不能做什么
本文介绍了 Node.js 如何通过类型剥离功能原生运行 .ts 文件,为何会跳过类型检查,以及何时仍需要真正的构建步骤。
你现在应该已经熟悉流程了。创建一个新的 TypeScript 项目,编写第一个 .ts 文件,尝试运行它,随即就会想起还有整套初始化步骤需要完成:引入 ts-node 或 tsx,配置 tsconfig.json,可能还需要设置构建脚本,并确定要使用 CommonJS 还是 ESM。这些步骤单独来看并不难,只是在编写实际应用逻辑之前会积累诸多繁琐工作,而且每次开始新项目时都会遇到这种情况。
今年,对于大多数实际的 Node.js 项目而言,整个繁琐流程已经完全消失了。只需输入 node file.ts 即可运行,无需任何参数、额外依赖或配置文件。这一变化悄然推出,并没有大规模的宣传,但它解决了那种平时一周内会反复出现的细微问题——而这些问题的积累确实值得深入探讨。
究竟发生了什么
其背后的机制被称为类型剥离,这个名字非常直观:Node.js 会解析你的 TypeScript 源代码,移除类型注解,然后执行剩余的纯 JavaScript 代码。这就是整个概念。
// Before: what you write
interface User {
name: string;
age: number;
}
function describeUser(user: User): string {
return `${user.name} is ${user.age} years old`;
}
// After: what Node.js actually executes, post-stripping
// (whitespace preserved, so line numbers stay accurate for debugging)
function describeUser(user) {
return `${user.name} is ${user.age} years old`;
}
interface声明完全消失了。: User和: string这样的注解也被移除。剩下的只是普通的、有效的JavaScript代码,V8会像往常一样对其进行执行——没有特殊的运行时机制,也没有任何填充代码,在执行阶段从概念上来说没有任何变化。
在幕后,这一过程是通过一个名为Amaro的库来实现的,它实际上是@swc/wasm-typescript的轻量级封装——后者是由SWC用Rust编写的TypeScript解析器的WebAssembly版本。其快速性能并非源于某种巧妙的优化,而是因为它所需处理的工作量远少于完整编译器。它不会跨多个文件解析类型,不会验证注释的正确性,也不会生成声明文件。它仅会解析语法树,剔除仅存在于TypeScript中的部分,然后输出JavaScript代码。正是这种有限的职责范围让它如此高效。
Node 对此功能的支持经历了多个阶段才形成现在的样子:在 v22.6.0 版本中已实现了对简单类型剥离的实验性支持,v22.7.0 版本则加入了用于处理枚举等更复杂结构的独立标志位,而在 v22.18.0 和 v24.3.0 版本中,该功能已默认稳定——这意味着符合支持语法范围的代码完全不需要任何标志位。值得注意的是,Node 后来彻底移除了那个专门用于枚举的标志位,选择坚持采用较为狭隘且可预测的功能范围,而非试图支持整个语言。
真实的局限
如果你打算依赖此功能,就必须明白这一点,而且需要明确指出:类型剥离与类型检查并非同一概念。
移除类型注解并不会首先验证其正确性——它只是将其删除而已。因此,一个存在实际类型错误的文件,比如在期望数字的地方传入了字符串,在进行类型剥离处理后仍能正常运行,因为当代码真正执行时,本应提示问题的类型信息早已消失。所有关于此主题的权威资料都给出相同建议:在 CI 流水线中单独设置 tsc --noEmit 步骤继续运行。类型剥离取代的是构建步骤,而非编译器检测错误的功能。
更重要的限制在于究竟哪些 TypeScript 语法符合被移除的条件。Node 只支持所谓的可删除语法——即那些在完全移除后也不会改变代码运行结果的语言结构。而 TypeScript 中有相当一部分并不满足这一要求,因为它们会生成真正的运行时行为,无法简单地被删除:
// ❌ Fails under type stripping — enums generate a real runtime object
enum Direction {
Up,
Down,
Left,
Right,
}
// ❌ Fails - parameter properties generate constructor assignment code
class Point {
constructor(public x: number, public y: number) {}
}
// ❌ Fails - this is a CommonJS-style module alias, not an erasable type
import fs = require('fs');
// ❌ Fails - angle-bracket type assertions look like real syntax to strip,
// but the parser can't tell it apart from JSX safely
const num = <number>someValue;
这些构造中的每一种都会导致程序直接崩溃,而不会只是默默地生成错误的构建结果——Node的代码简化机制就是刻意设计为在出现问题时停止并报错,而非试图猜测用户的真实意图。通过旧的experimentalDecorators标志启用的传统风格装饰器也因同样的原因遇到相同的问题。而TC39制定的新标准版装饰器则有所不同:它们的规范设计使得编译后的代码与普通JavaScript语法一致,因此无需再进行任何特殊简化处理,且能在Node环境中毫无问题地运行。
TypeScript的适应方式
为了避免开发人员在运行代码时逐个文件地偶然发现这些限制,TypeScript 团队迅速明确了相关规则。TypeScript 5.8 引入了一个新的编译器标志 --erasableSyntaxOnly,它能让 tsc 在编译过程中直接拒绝上述任何不可删除的代码模式。这样一来,“Node 能否实际运行这段代码”这个问题就从需要在运行时通过痛苦的方式才能发现的疑问,变成了可以提前设定的规则,成为对整个代码库的明确约束。
// tsconfig.json
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true // pairs well with this —
// keeps type-only imports explicit
}
}
即便你目前还没有计划移除构建步骤,开启此选项也是值得的,因为它能为你提供关于代码是否符合要求的明确、自动化的答案——而不是在代码出问题时才零散地发现问题。
这项迁移工作所需投入的精力差异很大,具体取决于当前的起点。对于新创建的后端服务或命令行工具,通常可以直接启用 erasableSyntaxOnly,几乎无需或完全不需要进行修改。而那些严重依赖 enum 声明的代码库,或是基于假定使用旧版装饰器的框架构建的代码库——比如较老版本的 NestJS 或 TypeORM——则往往需要大规模重写,或者人们会选择坚持传统的构建流程,而非一次性完成全部迁移。在做出任何决定之前,最可靠的估算方法就是启用 erasableSyntaxOnly,运行一次 tsc --noEmit,然后查看返回的错误数量。仅通过这一次运行,就能在修改任何运行时配置之前了解问题的实际范围。
实用指南
在那些已经完成这一转型的团队中,出现了一种相当一致的决策框架。
对于后端服务、命令行工具、内部实用程序以及独立脚本——即那些直接在 Node 环境下运行且未作为包供他人使用的程序,可以省略构建步骤。这正是代码剥离功能设计的初衷,而基于 Express 或 Fastify 构建的服务通常无需修改代码即可立即使用该功能。
对于任何需要在浏览器中运行的代码,都要保留相应的构建步骤,因为浏览器根本无法执行 .ts 文件——无论服务器上的 Node 支持什么功能,你仍然需要使用打包工具。此外,对于你发布的任何 npm 包,也需保留构建步骤,因为安装这些包的人需要编译后的 JavaScript 代码以及 .d.ts 声明文件,而且你无法保证他们的 Node 版本支持类型剥离功能。另外,对于那些仍依赖旧版装饰器或大量使用 enum 且尚未进行转换的代码库,同样需要保留构建步骤。
无论你选择哪种方式,都应在 CI 流水线中持续运行 tsc --noEmit 命令。仅删除构建步骤只会去掉编译环节——它从来就不是用来替代类型检查的,若将其视为 tsc 的替代品,才是真正可能导致安全问题隐患的做法。
核心要点
让这一变化值得关注的关键并非速度的提升,尽管更快的反馈循环确实能带来立竿见影的好处。它实际上反映了 TypeScript 在概念层面的发展方向。在其发展历程的大部分时间里,TypeScript 被视为一种需要编译成 JavaScript 的语言——即一种在运行前必须先进行转换的独立语言。而 Node 中的类型剥离功能则暗示着 TypeScript 正逐渐演变为更类似于 JavaScript 的一种变体,运行时可以直接按原样读取它,至少对于大多数开发者实际使用的那些常见语言特性而言是如此。当然这并非该语言的全部,也从来都不可能做到——枚举和传统装饰器仍然有实际应用场景,并不会消失。但对于那些不需要这些特性的代码来说,过去在编写 TypeScript 与实际运行之间必须经历的步骤已经不再必不可少。
这一变化幅度远比人们最初所认为的那些有限的关注要显著得多。相关阅读
- 在 Node 24 中用 Node 的原生测试运行器替代 Jest —— 一篇实际迁移案例展示了如何通过 Node 24 的内置测试运行器及对 TypeScript 的原生支持来缩短持续集成时间,同时减少四个依赖项。
- TypeScript 6 在通往原生 TS 7 编译器的道路上的桥梁作用 —— 了解 TypeScript 6 如何更新默认配置、模块解析机制及导入语法,为代码库适配更快、基于 Go 语言的 TypeScript 7 编译器做好准备。