我为什么把 TypeScript 的 NodeNext 改成 Bundler
太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
TypeScriptNode.jsESMBundlerNext.js

我为什么把 TypeScript 的 NodeNext 改成 Bundler

事情是从一条很普通的导入开始的:

import { buildAgentPrompt } from "./prompt.js";

这是 TypeScript 源码,旁边的文件明明叫 prompt.ts,为什么导入时偏要写成 .js

我一开始以为这是现在写 TypeScript 的推荐方式,或者某种能让编辑器检查得更准确的新规范。继续追下去才发现,它只适用于一条很具体的产物路线:TypeScript 编译出一批保持原目录结构的 JavaScript 文件,然后不经过打包,直接交给 Node.js 的 ESM 加载器运行。

我的项目并不是这样工作的。弄清这一点后,我把 NodeNext 改成了 Bundler,源码里的相对导入也恢复成了不带扩展名的写法。

“原生 Node ESM”到底是什么意思

这里的“原生”容易把人绕进去。它通常不是指 Node 直接运行 TypeScript,而是说最终的 JavaScript 不再经过 Webpack、Vite、esbuild 这类工具处理,直接由 Node 的 ESM 加载器解析。

例如 TypeScript 源码是:

import { buildAgentPrompt } from "./prompt.js";

编译后仍然是:

import { buildAgentPrompt } from "./prompt.js";

此时 dist/prompt.js 真实存在,Node 按照这个路径加载文件。

Node 的 ESM 解析更接近 URL,而不是以前 CommonJS 的文件查找习惯。相对导入必须写完整扩展名,目录入口也不能只写目录名。下面这种写法交给原生 Node ESM 会报错:

import { buildAgentPrompt } from "./prompt";

Node 不会依次猜测 prompt.jsprompt.jsonprompt/index.js。这条规则并不是 TypeScript 发明的。Node 从引入 ESM 时就在向浏览器的模块解析方式靠拢;ESM 后来在 Node 12.22、14.17 和 15.3 中标记为稳定。

TypeScript 的 NodeNext 只是照着最终运行环境检查源码。它看到 ./prompt.js 时,会在开发阶段找到对应的 prompt.ts 做类型检查,同时保留导入字符串,让编译后的 JavaScript 能被 Node 找到。

所以 .js 写在 .ts 文件里虽然别扭,却不是写错了。只是我之前没有意识到,它在描述未来的产物,而不是当前磁盘上的源码。

它能多检查出什么

我最关心的问题是:这样写能不能让编码提示更好,或者多发现一些逻辑错误?

答案是不能。

变量类型、函数参数、返回值和控制流分析仍然由 TypeScript 的类型系统负责。相对导入写 .js 不会让这些检查突然变强。NodeNext 真正多做的是运行环境检查,比如:

  • 原生 Node ESM 的相对导入是否缺少扩展名。
  • package.json 中的 exportsimports 能否按 Node 规则解析。
  • 当前文件会被当成 ESM 还是 CommonJS。
  • JSON 导入是否符合当前 Node ESM 的写法。

它避免的是“TypeScript 没报错,编译后的文件却被 Node 拒绝加载”这类问题。前提是那些文件最终真的会原封不动地交给 Node。

如果项目本来就会打包,情况不同。打包器读取的是 TypeScript 源码,它知道怎样补扩展名、寻找目录入口,也可能把多个文件合成一个文件。此时继续让 NodeNext 强迫源码模拟 Node 的产物规则,我觉得是在检查一个不会出现的运行过程。

Node 现在不是也能直接运行 TypeScript 吗

现在的 Node 确实已经支持直接运行一部分 TypeScript。Node 22.18 开始默认启用类型擦除,较新的版本里这项能力也已经稳定。

但它没有把 TypeScript 变成另一套模块系统。直接运行 main.ts 时,相对导入仍然要写真实存在的扩展名:

import { buildAgentPrompt } from "./prompt.ts";

Node 只擦除能够安全删除的类型语法,不会读取 tsconfig.json,也不会处理 paths 别名、降级语法或完整的 TypeScript 转换。enum、带运行时代码的 namespace、参数属性等语法还需要额外转换。

所以“Node 能运行 TypeScript”和“Node ESM 可以省略扩展名”是两回事。前者改变了输入文件类型,后者的解析规则没有因此消失。

TypeScript 为什么不自动处理后缀

我还疑惑过,既然 TypeScript 知道 prompt.ts 最后会变成 prompt.js,为什么不自动改?

原因是 TypeScript 不知道谁来执行输出。可能是 Node,可能是浏览器,也可能是某个打包器。导入路径还可能经过 pathspackage.jsonexports、自定义加载器或其他工具处理。擅自改写字符串,很容易让某些运行环境得到错误结果。

因此 TypeScript 长期采用的做法是:源码里的模块说明符默认原样输出,由开发者按照目标运行环境来写。

这也解释了为什么 NodeNext 要求我在源码里提前写 .js。它选择保留导入,而不是在编译时猜测。

我也考虑过 rewriteRelativeImportExtensions

TypeScript 5.7 加入了 rewriteRelativeImportExtensions。开启后,可以在源码中写:

import { buildAgentPrompt } from "./prompt.ts";

tsc 输出 JavaScript 时会改成:

import { buildAgentPrompt } from "./prompt.js";

这套方案很适合“开发时直接运行 TypeScript,发布时再编译成 JavaScript”的项目。源码引用真实的 .ts 文件,输出也能交给 Node。

我最后没有采用它,因为它没有解决我当前的问题。项目中的 tsc 只负责 noEmit 类型检查,真正的 JavaScript 由打包器生成。既然 TypeScript 根本不输出文件,也就没有路径需要它重写。

它还有一些明确的限制。只有以 ./../ 开头、并且直接写出 .ts.tsx.mts.cts 的路径才会改写。下面这些不会处理:

import { value } from "@/utils.ts";
import { value } from "#root/utils.ts";
import { value } from "some-package/utils.ts";

const path = getModulePath();
const module = await import(path);

路径别名、包名、package.json 子路径以及运行时计算出来的动态导入,都不在它的处理范围内。多包项目还可能需要条件导出,才能让开发环境读取源码、发布环境读取 JavaScript。

这个选项本身没有问题,只是不能把它当成通用的 ESM 路径修复器。

为什么 Next.js 项目很少纠结这个

我拿 Next.js 做了对照。常见的 Next.js TypeScript 配置使用:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true
  }
}

源码里通常直接写:

import { Button } from "./button";

原因很简单。Next.js 会处理模块,tsc 主要负责类型检查。源码不会被 TypeScript 原样编译成一堆文件,再直接交给 Node ESM。让模块解析配置贴近打包器,反而更符合真实运行方式。

这也是我最后判断配置的标准。我不再先问“现在流行哪一种”,而是先看谁在消费源码、谁在生成 JavaScript、生成的文件最后由谁加载。

我的选择

这个问题本身和 Bun 没关系。Node ESM 的扩展名规则就是 Node 的规则,不会因为换一个包管理器或运行工具而改变。

但在我的项目里,JavaScript 确实由构建工具输出到 disttsc 只执行 noEmit 检查。这里没有“TypeScript 逐文件编译,Node 逐文件加载”的环节。继续使用 NodeNext,等于为了一个不存在的运行路径修改所有源码导入。

我最后使用了这份配置:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

源码恢复成:

import { buildAgentPrompt } from "./prompt";

Bundler 仍然会读取 package.jsonexportsimports,也不会削弱普通的类型检查。它只是不再要求相对导入遵守原生 Node ESM 的扩展名规则。

当然,这个选择有边界。如果以后要发布一个不打包的 ESM 库,或者直接让 Node 运行 tsc 生成的一组 JavaScript 文件,我会重新使用 NodeNext。到时可以在 TypeScript 源码里写 .js,也可以写 .ts 并配合 rewriteRelativeImportExtensions。如果发布的声明文件还保留了无扩展名相对导入,也需要重新检查,因为使用 NodeNext 的消费者可能无法解析。

我现在不想为了假设中的兼容性折磨源码。运行方式已经确定,就让 TypeScript 配置忠实描述它。对我这个项目来说,ESNext + Bundler + noEmit 就是更直接的选择。