Node 对原生 TypeScript 的支持:7 个实际应用中的问题及解决方案
了解哪些 TypeScript 特性会在生产环境中被 Node 内置的类型剥离功能悄悄破坏,以及已在 Node 22.18+ 和 24.x LTS 上验证有效的具体配置修复方案。
一个正在迁移小型 Express 服务的团队决定在测试环境中放弃使用 ts-node,直接通过 node file.ts 来运行应用。在本地测试时这种做法看似没问题,但到了下一个工作日,CI 流水线就开始出现构建失败的情况,一些开发者在终端中遇到了 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 错误,而最终发布的生产环境热修复版本甚至完全没有进行任何类型检查,因为那层安全保障早已悄然消失。
Node 对 TypeScript 的原生处理方式——即类型剥离功能——确实相当出色。但其支持的 TypeScript 特性范围其实比大多数人想象的要窄,只有在实际使用中遇到问题时这些局限性才会显现出来。以下是在一次实际迁移过程中出现的七个问题,包括具体的错误信息,以及针对 Node 22.18+ 和 24.x LTS 验证过的修复方案。
宣传与现实
Node 通过内置的 swc 版本在运行时移除类型注解的方式来执行 TypeScript,它从不调用 TypeScript 编译器,也不会经历 tsc 阶段。因此,它没有类型检查功能,不会对较旧的目标版本进行语法转换,无法解析路径别名,不支持 .tsx 文件,没有装饰器支持,不存在枚举类型,也不会为命名空间生成运行时代码。
虽然可以运行 node file.ts,这确实是一项功能,但它仅代表 TypeScript 的最基本功能,而非其全部特性。
1. 生产环境中我的相对导入会静默返回 404 错误
症状表现。在本地开发时一切正常,但部署后应用在启动时会出现类似这样的错误:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/dist/utils/hash.js'
imported from /srv/app/dist/server.js
发生了什么。类型剥离功能仅会移除注解,文件中的其他所有标记都会保持不变,包括导入路径。因此像这样的源文件:
// src/server.ts
import { hashToken } from "./utils/hash.js";
会原样被传递过去。在本地开发时,node --experimental-strip-types能够智能地识别出./utils/hash.ts,即便其扩展名显示为.js。但在构建过程中(使用tsc将代码编译到dist文件夹中),字符串中仍保留着原始的.js扩展名,而dist/utils/目录下并没有对应的.js文件——只有.ts格式的源文件被编译到了其他地方。
解决方案。需要同时进行两项独立的修改。
首先,写入与磁盘上实际存在的文件扩展名相匹配的导入扩展名——即 .ts,而非 .js:
// src/server.ts
import { hashToken } from "./utils/hash.ts";
其次,需告知编译器这是有意为之,并让其在输出时转换扩展名:
{
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true
}
}
关键设置是 rewriteRelativeImportExtensions: true,它在 tsc 生成输出时将 ./utils/hash.ts 转换回 ./utils/hash.js,这样编译后的 JavaScript 对下游使用它的所有程序依然能正常运行。noEmit: true 在此处是必需的——如果没有它,allowImportingTsExtensions 会引发 TS5096 错误。详情请参阅 TypeScript 文档。
2. 我一半的代码库被判定为“不支持的语法”
症状。有位开发者在切换后进行的首次提交时就遇到了此错误:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum declarations are
not supported by Node's type stripping. Convert enums to objects with `as const`
or use a transformer.
还有人在类构造函数方面遇到了类似的问题:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter properties
are not supported. Use an explicit field declaration instead.
发生的原因。Node的类型剥离引擎故意只支持有限范围的语法:那些可以完全删除而不会改变运行时行为的构造,即所谓的“可消除”语法。任何会在运行时实际生成JavaScript逻辑的TypeScript特性都会被以运行时异常的方式拒绝,而不会被转换。
来自官方 Node TypeScript 文档的不可支持结构完整列表包括:必须通过as const转换为字符串联合类型或对象的enum声明;包含运行时逻辑的namespace块,其导出值需移至普通模块导出中(仅包含类型的namespace则不受影响);构造函数中的参数属性(如constructor(public x: number)),这类情况需要使用显式的字段声明;导入别名,必须在导入时重新命名;装饰器,它们在解析阶段就会失败且不会被填充实现;以及.tsx文件,因为只有.ts、.mts和.cts扩展名会被识别。
解决方案。 枚举值的解析方式如下:
// before ; dies at runtime
enum Role { Admin = "admin", User = "user" }
// after ; works under type stripping AND in tsc
const Role = {
Admin: "admin",
User: "user",
} as const;
type Role = (typeof Role)[keyof typeof Role];
对于装饰器,最安全的方法是等待 Node 的解析器原生支持 TC39 的装饰器提案后再使用它们,或者为依赖这些装饰器的文件在处理流程中加入 swc 或 tsc 等转换工具。在 tsconfig.json 中设置 "erasableSyntaxOnly": true 也是值得的——这样编译器会在代码到达运行时之前,就在编辑器中直接标出不支持的语法。
3. 类型检查不会静默进行
症状。某处理程序在期望接收字符串类型的数据时却接受了null值,随后在调用其..length方法时崩溃。该路由对应的单元测试却顺利通过,没有出现任何问题。该变量被定义为string类型,实际值却是null,而node file.ts在处理这两种情况时也都没有异常。
app.post("/webhook", (req, res) => {
const body: string = req.body.payload; // null sneaks in, no one notices
console.log(body.length);
});
发生的原因。类型剥离仅在文本层面起作用——它从不参考类型检查器。在node执行流程中,没有任何机制能够验证代码中传递的数值是否与其声明的类型相符。
可以说,这是放弃使用ts-node后带来的最危险的隐性故障模式。即使node file.ts能够成功运行,也无法说明代码在类型方面是否正确。
解决方案。将类型检查重新作为独立的、明确的步骤加入。
// package.json
{
"scripts": {
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"ci": "npm run typecheck && npm run lint"
}
}
在每次拉取请求的 CI 流程中运行 tsc --noEmit,如果符合你们的工作流程,还可以考虑将其集成到预提交钩子中。原生运行时负责执行代码,而 tsc 则是用于检测类型错误的工具。这两项功能现已完全解耦,这种分离正是 Node 设计中的有意安排。
在 tsconfig.json 中同时设置 "erasableSyntaxOnly": true 和 "verbatimModuleSyntax": true 也是值得的。第一个设置会让 tsc 拒绝那些类型剥离功能无法处理的代码——从而在编译时而非运行时捕获装饰器或枚举问题。第二个设置则要求必须使用显式的 import type 语句,以避免仅导入类型时留下多余的运行时导入语句。
4. 我的 @/utils/* 路径别名不再生效
症状。 一个常见的错误:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '@/utils/logger'
imported from /srv/app/src/server.ts
问题所在。 tsconfig.json 中的 paths 字段纯粹是为 TypeScript 提供的编译时便利功能——Node 本身从未理解过它。ts-node 和 tsx 等工具之所以能识别它,是因为它们在 Node 之上实现了自己的模块解析逻辑。而原生类型剥离工具则不会这样做,它会直接将解析任务交给 Node 的加载器处理。
// tsconfig.json ; this never worked at runtime, it only worked in your editor
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/utils/*": ["src/utils/*"] }
}
}
解决方案。 根据应用程序的部署方式,有三种可行的解决途径。
选项 A:使用 Node 内置的子路径导入功能。 完全删除 tsconfig 中的 paths 设置,改为在 package.json 中声明映射关系:
{
"imports": {
"#utils/*": "./src/utils/*"
}
}
// src/server.ts
import { logger } from "#utils/logger.ts";
在 node、tsc --noEmit 以及 vitest 环境下,无需任何额外配置即可正确解析。开头的 # 是 Node 的惯例,表示“这是内部别名,而非正式发布的包”。迁移操作只需在整个项目中将 @/utils/ 替换为 #utils/ 即可。
选项 B:改用相对导入并接受 ../ 的路径结构。 虽然较为繁琐,但不需要任何额外的工具支持。
选项C:保留路径重写转换器。像tsc-alias这样的工具会在编译后重写生成的JavaScript代码,或者你可以让tsx/swc在运行时解析别名。这样做又会引入你原本试图避免的构建步骤,从而削弱了原生TypeScript的诸多优势。在2026年之前,这并非值得采用的方案。
5. CommonJS与ESM的互操作性让我措手不及
症状表现。原本一直能正常工作的require("openai")调用突然出现了错误:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... openai ... not supported.
或者,反过来,没有扩展名的目录导入也会出问题:
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import ... is not supported
under ESM
问题所在。此前,您的源文件会通过 tsc 编译为 dist/ 目录下的 .js 文件,此时 require() 能够按预期工作。而在原生 TypeScript 执行模式下,Node 实际加载的文件是 .ts 文件本身,Node 会根据最近的 package.json 中的 "type" 字段来判断该文件应作为 CommonJS 还是 ESM 使用。如果该字段值为 "module",则作用域内的所有 .ts 文件都被视为 ESM,此时任何残留的 require() 调用都会出错。如果该字段缺失(默认为 CommonJS),则会出现相反的问题:只能以 ESM 方式导入的依赖将无法加载。
解决方案。选定一种模块系统,并在整个项目中统一使用它。
如果从零开始,可在 package.json 中设置 "type": "module",所有代码都用 ESM 格式编写,而将 .mts/.cts 扩展名保留给那些确实需要采用另一种格式的少数文件。
// package.json
{
"type": "module",
"engines": { "node": ">=22.18.0" }
}
// src/server.ts
import { readFile } from "node:fs/promises"; // ESM, native
import OpenAI from "openai"; // pure ESM upstream
const openai = new OpenAI();
对于现有的 CommonJS 代码库,应保持 "type": "commonjs"(或不设置该字段)——并且避免在不通过动态 import() 的情况下,从 CommonJS 代码中引入纯 ESM 包。Node 22.12+ 确实支持稳定的 require(esm),但依赖它仍会带来双重包管理的问题,还会使构建过程变得脆弱。更安全的方法是将调用该功能的文件转换为 ESM 格式,或是在 async 函数中通过动态导入来使用该依赖项。
这里还有第二个陷阱:目录导入。在 ESM 模式下,使用 import x from "./folder" 时不会像以前那样自动解析为 ./folder/index.ts,你需要明确指定文件路径:
// bad
import { routes } from "./routes";
// good
import { routes } from "./routes/index.ts";
6. 监视模式与热重载功能有所退步
症状表现。从 tsx watch src/server.ts 切换到 node --watch src/server.ts 后,出现了多处问题:
- 重启速度——虽然
node --watch仍然可用,但速度明显变慢。 - 当项目根目录外的导入文件发生更改时,无法可靠地实现热重载。
- 无法通过
SIGUSR2手动触发重启。 - 失去了彩色输出以及“按 R 键重启”的友好提示。
node_modules、dist 以及 .test.ts 文件都有合理的默认排除设置。问题所在。 node --watch 是 Node 早已内置的文件监控工具。由于类型信息的去除,它现在也能处理 .ts 文件,但它从来就不是为替代 tsx watch 或 nodemon 这类专用工具而设计的——它更像是一种基础功能。
解决方案。 如果只是需要在单个脚本有变动时强制重启,可以使用 node --watch。而对于存在多层导入关系、需要真正开发或测试循环的服务器环境,还是应该使用 tsx watch。即使将生产环境执行任务转交给原生 TypeScript,继续使用 tsx 作为开发运行工具也完全没有问题。
// package.json ; pragmatic split
{
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --enable-source-maps src/server.ts",
"start:native": "node src/server.ts"
}
}
tsx 基于 esbuild 运行,速度远超 tsc — 根据该项目自身的测试数据,速度大约快 20 到 30 倍 — 它可直接识别路径别名,其行为也类似于在监控模式得到更多重视时的 node --watch。
7. 跳过构建步骤只是转移了问题
症状。在宣布团队将放弃 tsc 而直接以原生方式运行代码后,几乎立刻出现了一些问题:
- 发布到 npm 的 SDK 需要为后续使用者提供
.d.ts声明文件,而原生 TypeScript 执行方式不会生成这些文件。 - Lambda 部署目标期望 CommonJS 格式的输出,但代码却是以 ESM 方式编写的。
.ts 源文件,而非编译后的输出。问题所在。 代码剥离是在运行时进行的,而非构建时——这正是该功能的意义所在。但一旦你的代码需要在“Node 22 或更高版本、直接从仓库运行”的环境之外执行,就又需要经过编译步骤。这一环节并没有消失,只是转移到了流程中的其他阶段。
解决方案。 明确指定你实际要传输的内容类型。
如果你正在开发一个应用程序——即由自己部署和运行的服务——使用原生 TypeScript 确实能带来显著提升。无需进行构建过程,冷启动速度更快,而且 Dockerfile 也会更简单,因为你可以直接使用 COPY src ./src,无需管理 dist/ 文件夹。
# Dockerfile
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY tsconfig.json ./
CMD ["node", "--enable-source-maps", "src/server.ts"]
如果你正在维护一个打算发布到 npm 的库,则应在实际生成代码的步骤中保留 tsc。在开发和测试阶段可以使用原生 TypeScript,但最终发布的包仍需同时包含编译后的 .js 文件和 .d.ts 文件。
// package.json ; library case
{
"scripts": {
"dev": "node --watch src/index.ts",
"build": "tsc",
"test": "node --test --experimental-strip-types test/*.test.ts"
}
}
如果你的目标平台是无服务器环境、边缘运行时,或使用 Node 20.x 的客户端,你仍然需要一个转译工具——无论是 swc 还是 tsc——并将其配置为输出旧版运行时能够执行的代码。你以为已经省去的构建步骤在那里依然是必需的。
这样做值得吗?真实的评价。
自带的 TypeScript 支持或许是自 async/await 引入以来 Node 最重大的改进——但这一优点也伴随着重要的限制条件。
以下情况适合采用该功能:
- 在 Node 22.18+ 或 24.x LTS 版本上部署自托管服务。
- 已经编写出简洁且易于维护的 TypeScript 代码——没有枚举,没有装饰器,全程使用纯 ESM 语法。
- 配置了运行
tsc --noEmit的 CI 任务,确保类型检查不会从你的工作流程中悄然消失。
node_modules 中的依赖数量。如果符合以下情况,建议继续使用 tsx 或 tsc:
- 需要发布可在旧版 Node 环境中运行的库供用户使用。
- 严重依赖 NestJS、TypeORM、class-validator 或其他基于实验性装饰器的工具。
- 需要为服务端渲染的 React 组件提供
.tsx支持。 - 目前仍在使用
tsconfig的路径别名,尚未准备切换到imports字段。 - 还缺乏自律性(或相应工具)来避免在代码库中出现不可删除的语法结构。
最终让此方案可行的配置:
// tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
// package.json (snippet)
{
"type": "module",
"engines": { "node": ">=22.18.0" },
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --enable-source-maps src/server.ts",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
"ci": "npm run typecheck && npm test"
}
}
这就是全部的配置内容。看不到任何 ts-node 的踪迹,运行时执行路径中也没有 tsc。更没有庞大的 nodemon.json 文件。一个工具负责开发环境,一个负责类型检查,还有一个负责生产环境。需要明确的是,node_modules 依然像以前一样臃肿——Node 生态系统的这一方面自2009年以来毫无变化。
真正的益处并不在于ts-node会被从你的依赖项中移除,而在于你不再误以为删除了ts-node就等于去掉了构建步骤。原生 TypeScript 执行方式虽然体积更小、速度更快且更为透明,但它依然属于构建过程。这次迁移并非从“有构建流程”变为“没有构建流程”,而是将原本不可见的构建步骤转变为你能真正理解的步骤。
相关阅读
- Node.js 原生 TypeScript 支持的实际功能与局限 — 本文阐述了 Node.js 如何通过类型剥离来原生运行.ts文件、为何会跳过类型检查,以及何时仍需要真正的构建步骤。