首页 / 文章 / Node 对原生 TypeScript 的支持:7 个实际应用中的问题及解决方案

Node 对原生 TypeScript 的支持:7 个实际应用中的问题及解决方案

了解哪些 TypeScript 特性会在生产环境中被 Node 内置的类型剥离功能悄悄破坏,以及已在 Node 22.18+ 和 24.x LTS 上验证有效的具体配置修复方案。

2809 词

一个正在迁移小型 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";

nodetsc --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_modulesdist 以及 .test.ts 文件都有合理的默认排除设置。
  • 问题所在。 node --watch 是 Node 早已内置的文件监控工具。由于类型信息的去除,它现在也能处理 .ts 文件,但它从来就不是为替代 tsx watchnodemon 这类专用工具而设计的——它更像是一种基础功能。

    解决方案。 如果只是需要在单个脚本有变动时强制重启,可以使用 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 方式编写的。
  • 仍在使用 Node 20.x 的一位同事尝试安装该包,但根本无法成功——运行时环境不支持所依赖的代码剥离功能。
  • Docker 镜像体积变大,因为现在传输的是原始的 .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 任务,确保类型检查不会从你的工作流程中悄然消失。
  • 希望实现更快的冷启动、更精简的 Dockerfile,以及减少 node_modules 中的依赖数量。
  • 如果符合以下情况,建议继续使用 tsxtsc

    • 需要发布可在旧版 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 24 中用 Node 的原生测试运行器替代 Jest —— 一个实际迁移案例展示了如何通过使用 Node 24 的内置测试运行器及原生 TypeScript 支持来缩短持续集成时间,同时减少四个依赖项。