我为什么把 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.js、prompt.json 或 prompt/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中的exports和imports能否按 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,可能是浏览器,也可能是某个打包器。导入路径还可能经过 paths、package.json 的 exports、自定义加载器或其他工具处理。擅自改写字符串,很容易让某些运行环境得到错误结果。
因此 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 确实由构建工具输出到 dist,tsc 只执行 noEmit 检查。这里没有“TypeScript 逐文件编译,Node 逐文件加载”的环节。继续使用 NodeNext,等于为了一个不存在的运行路径修改所有源码导入。
我最后使用了这份配置:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}源码恢复成:
import { buildAgentPrompt } from "./prompt";Bundler 仍然会读取 package.json 的 exports 和 imports,也不会削弱普通的类型检查。它只是不再要求相对导入遵守原生 Node ESM 的扩展名规则。
当然,这个选择有边界。如果以后要发布一个不打包的 ESM 库,或者直接让 Node 运行 tsc 生成的一组 JavaScript 文件,我会重新使用 NodeNext。到时可以在 TypeScript 源码里写 .js,也可以写 .ts 并配合 rewriteRelativeImportExtensions。如果发布的声明文件还保留了无扩展名相对导入,也需要重新检查,因为使用 NodeNext 的消费者可能无法解析。
我现在不想为了假设中的兼容性折磨源码。运行方式已经确定,就让 TypeScript 配置忠实描述它。对我这个项目来说,ESNext + Bundler + noEmit 就是更直接的选择。