TypeScript 模块参考 · 编译器选项指南
按运行环境选择 TypeScript 模块编译配置
类型检查所理解的模块规则,需要与代码最终运行时的规则一致。这篇官方指南分别讨论打包应用、Node.js、浏览器原生模块和库发布,并说明声明文件与双格式输出容易遗漏的兼容边界。
原文:Modules – Choosing Compiler Options。作者与贡献者:TypeScript 文档贡献者;当前页面列出 Andrew Branch、Tom Mrazauskas。页面标注最后更新于 2026 年 10 月 5 日,未注明首次发表日期。中文翻译与原创示意图:未完纪。

我正在开发应用
一份 tsconfig.json 只能描述一种环境,这既包括有哪些全局对象可用,也包括模块如何工作。如果应用包含服务器代码、DOM 代码、Web Worker 代码、测试代码,以及供这些环境共同使用的代码,那么每一部分都应有自己的 tsconfig.json,并通过项目引用连接起来。然后,针对每份 tsconfig.json 分别使用本指南。
对于应用内部具有库性质的项目,尤其是需要在多个运行时环境中运行的项目,请参阅“我正在编写库”一节。
我使用打包器
除了采用下面的设置,原文目前还建议:在打包器项目中,不要设置 { "type": "module" },也不要使用 .mts 文件。某些打包器在这些情况下会采用不同的 ESM/CJS 互操作行为,而 TypeScript 目前无法在 "moduleResolution": "bundler" 下分析这种差异。详情见 issue #54102。
{
"compilerOptions": {
// 这不是完整模板,只展示与模块有关的设置。
// 请同时设置 target、lib 和 strict 等其他重要选项。
// 必需
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
// 请查阅所用打包器的文档
"customConditions": ["module"],
// 建议
"noEmit": true, // 或使用 emitDeclarationOnly
"allowImportingTsExtensions": true,
"allowArbitraryExtensions": true,
"verbatimModuleSyntax": true, // 或使用 isolatedModules
}
}
我先编译,再在 Node.js 中运行输出
如果打算输出 ES 模块,记得设置 "type": "module",或使用 .mts 文件。
{
"compilerOptions": {
// 这不是完整模板,只展示与模块有关的设置。
// 请同时设置 target、lib 和 strict 等其他重要选项。
// 必需
"module": "nodenext",
// "module": "nodenext" 隐含以下设置:
// "moduleResolution": "nodenext",
// "esModuleInterop": true,
// "target": "esnext",
// 建议
"verbatimModuleSyntax": true,
}
}
我使用 ts-node
ts-node 尝试兼容这样一套代码与 tsconfig.json 设置:它们也能够用于编译代码,并在 Node.js 中运行输出的 JavaScript。更多信息请参阅 ts-node 文档。
我使用 tsx
默认情况下,ts-node 对 Node.js 模块系统的修改很少;tsx 的行为则更接近打包器,允许省略扩展名、通过目录下的 index 文件解析模块说明符,也允许任意混用 ESM 与 CJS。使用 tsx 时,应采用与打包器相同的设置。
我为浏览器编写 ES 模块,不使用打包器或模块编译器
TypeScript 目前没有专门针对这一场景的选项。不过,可以结合使用 nodenext 的 ESM 模块解析算法和 paths,近似实现所需行为,用 paths 补足 URL 与 import map 的支持。
// tsconfig.json
{
"compilerOptions": {
// 这不是完整模板,只展示与模块有关的设置。
// 请同时设置 target、lib 和 strict 等其他重要选项。
// 在本地 package.json 中同时设置 "type": "module",
// 即可要求相对路径导入必须包含文件扩展名。
"module": "nodenext",
"paths": {
// 将远程 URL 对应到本地类型声明:
"https://esm.sh/lodash@4.17.21": ["./node_modules/@types/lodash/index.d.ts"],
// 可选:将裸模块说明符导入指向空文件,
// 从而禁止导入此处未列出的 node_modules 模块说明符:
"*": ["./empty-file.ts"]
}
}
}
这样配置后,明确列出的 HTTPS 导入可以使用本地安装的类型声明文件;通常会解析到 node_modules 的导入则会报错:
import {} from "lodash";
// ^^^^^^^^
// File '/project/empty-file.ts' is not a module. ts(2306)
这条诊断的意思是:文件 /project/empty-file.ts 不是模块。
另一种做法是使用 import map,在浏览器中把一组裸模块说明符明确映射到 URL,同时依靠 nodenext 默认的 node_modules 查找规则,或依靠 paths,让 TypeScript 找到这些导入对应的类型声明文件:
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/lodash@4.17.21"
}
}
</script>
import {} from "lodash";
// 浏览器:https://esm.sh/lodash@4.17.21
// TypeScript:./node_modules/@types/lodash/index.d.ts
我正在编写库
库作者选择编译设置的过程,与应用作者有本质区别。开发应用时,所选设置用来反映运行时环境或打包器的行为——通常只需面对一种行为已知的环境。编写库时,理想做法是在库使用者可能采用的所有编译设置下检查代码。既然这不现实,就可以改为采用尽可能严格的设置,因为满足这些设置,通常也能满足其他设置。
{
"compilerOptions": {
"module": "node18",
"target": "es2020", // 设为你支持的最低 target
"strict": true,
"verbatimModuleSyntax": true,
"declaration": true,
"sourceMap": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
}
}
下面逐一解释为何选择这些设置。
module: "node18"
一套代码只要兼容 Node.js 的模块系统,几乎总能在打包器中工作。如果使用第三方代码生成工具输出 ESM,请确保在 package.json 中设置 "type": "module",让 TypeScript 按 ESM 检查代码。在 Node.js 中,ESM 使用的模块解析算法比 CommonJS 更严格。
例如,假设某个库使用 "moduleResolution": "bundler" 编译下面的代码:
export * from "./utils";
只要 ./utils.ts(或 ./utils/index.ts)存在,打包器就能处理这段代码,因此 "moduleResolution": "bundler" 不会报告问题。如果用 "module": "esnext" 编译,这条导出语句生成的 JavaScript 会与输入完全相同。将这样的 JavaScript 发布到 npm 后,使用打包器的项目可以使用它,但在 Node.js 中运行时会报错:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js
Did you mean to import ./utils.js?
错误提示找不到导入的模块,并询问是否原本想导入 ./utils.js。如果改写为:
export * from "./utils.js";
生成的输出就能同时在 Node.js 和打包器中工作。
"moduleResolution": "bundler" 的影响会传递给使用者:它允许生成只能在打包器中工作的代码。同样,"moduleResolution": "nodenext" 也只检查输出能否在 Node.js 中工作。不过,大多数情况下,在 Node.js 中可用的模块代码,在其他运行时和打包器中也能工作。
target: "es2020"
把这个值设为你准备支持的最低 ECMAScript 版本,可以确保输出代码不会使用后来版本才引入的语言特性。由于 target 还隐含对应的 lib 值,这也能避免访问旧环境中可能不存在的全局对象。
strict: true
如果不启用它,你编写的类型层面的代码可能进入输出的 .d.ts 文件,并在使用者开启 strict 编译时产生错误。例如下面的 extends 子句:
export interface Super {
foo: string;
}
export interface Sub extends Super {
foo: string | undefined;
}
这段代码只有在启用 strictNullChecks 时才会报错。反过来,只在关闭 strict 时才报错的代码很难写出来,因此强烈建议库在编译时启用 strict。
verbatimModuleSyntax: true
这个设置可以防止几类与模块有关的陷阱,避免给库的使用者带来问题。首先,它会阻止你写出含义不明确的导入语句——这类语句的解释方式可能取决于使用者设置的 esModuleInterop 或 allowSyntheticDefaultImports 值。过去,人们常建议库不要启用 esModuleInterop,因为库使用它可能迫使使用者也启用它。
但也有一些导入写法只在关闭 esModuleInterop 时才能工作。因此,无论这个选项设为哪个值,都不能保证库的可移植性。verbatimModuleSyntax 则可以对这一点提供保证。[1]
其次,它会禁止在最终输出为 CommonJS 的模块中使用 export default。这种写法可能迫使打包器使用者和 Node.js ESM 使用者以不同方式使用模块。更多细节见 ESM/CJS 互操作附录。
declaration: true
这个设置会在输出 JavaScript 的同时生成类型声明文件。库的使用者需要这些声明文件,才能获得类型信息。
sourceMap: true 与 declarationMap: true
这两个设置分别为输出的 JavaScript 和类型声明文件生成源映射。只有在库同时发布源文件(.ts)时,这些映射才有用。将源映射和源文件一起发布,使用者调试库代码会方便一些。将声明映射和源文件一起发布,使用者对从库中导入的符号执行“转到定义”时,就能看到原始 TypeScript 源码。
两者都涉及开发体验与库体积之间的取舍,是否包含它们由你决定。
rootDir: "src" 与 outDir: "dist"
使用单独的输出目录总是一个好习惯;对于会发布输入源文件的库来说,这更是必要条件。否则,文件扩展名替换会让库的使用者加载库的 .ts 文件,而不是 .d.ts 文件,从而造成类型错误和性能问题。
打包库时需要考虑什么
如果使用打包器生成库,那么所有未被外置的导入都会由行为已知的打包器处理,而不是交给使用者那些无法预知的环境。在这种情况下,可以使用 "module": "esnext" 和 "moduleResolution": "bundler",但要满足下面两点。
一、部分文件打包、部分依赖外置时,TypeScript 无法完整建模模块解析
打包一个带有依赖的库时,常见做法是把库自身的源码打包成一个文件,同时让外部依赖的导入在打包结果中继续保留为真正的导入。这实际上意味着模块解析分别由打包器和最终使用者的环境承担。
要在 TypeScript 中描述这种情况,理想做法是用 "moduleResolution": "bundler" 处理将被打包的导入,再用 "moduleResolution": "nodenext" 处理外置的导入;或者采用多组设置,检查各种最终使用环境中都能正常工作。但 TypeScript 无法在同一次编译中使用两种不同的模块解析设置。
因此,使用 "moduleResolution": "bundler" 可能会放行某些外置依赖的导入:它们在打包器中可以工作,在 Node.js 中却不安全。另一方面,使用 "moduleResolution": "nodenext" 又可能对将被打包的导入施加过于严格的要求。
二、必须确保声明文件也经过打包
请记住声明文件的第一条规则:每个声明文件都恰好对应一个 JavaScript 文件。如果采用 "moduleResolution": "bundler",用打包器输出一个 ESM 包,却用 tsc 输出许多独立的声明文件,那么使用者在 "module": "nodenext" 下使用这些声明时,可能会遇到错误。例如下面的输入:
import { Component } from "./extensionless-relative-import";
JavaScript 打包器会移除这条导入,但生成的声明文件中可能仍保留完全相同的导入语句。由于缺少文件扩展名,这条语句中的模块说明符在 Node.js 中无效。对于 Node.js 使用者,TypeScript 会对该声明文件报错,并让引用 Component 的类型受到 any 的影响,因为它判断这个依赖会在运行时崩溃。
如果所用 TypeScript 打包器不会输出打包后的声明文件,就应使用 "moduleResolution": "nodenext",确保声明文件中保留下来的导入能够兼容最终使用者的 TypeScript 设置。更好的办法是,考虑是否根本不需要打包这个库。
关于同时输出两种模块格式的方案
一次 TypeScript 编译,无论负责生成文件还是仅做类型检查,都假定每个输入文件只会生成一个输出文件。即使 tsc 什么文件都不生成,它检查导入名称时,仍要根据 tsconfig.json 中与模块和输出有关的选项,了解输出文件在运行时会如何表现。
只要能够把 tsc 配置成理解另一个生成工具会输出什么,将第三方生成工具与 tsc 类型检查配合使用通常是安全的。但如果某个方案生成两套模块格式不同的输出,却只进行一次类型检查,就会让至少其中一套输出没有得到检查。
外部依赖可能向 CommonJS 和 ESM 使用者暴露不同的 API,因此不存在某种配置,可以在一次编译中保证两套输出都具备类型安全性。实践中,多数依赖会遵循最佳实践,因此双格式输出往往能够工作。在发布前对所有输出包运行测试和静态分析,能显著降低严重问题未被发现的概率。
原文脚注
[1] 只有当 JavaScript 生成工具输出的模块种类,与 tsc 根据 tsconfig.json、源文件扩展名以及 package.json 中的 "type" 所判断的模块种类一致时,verbatimModuleSyntax 才能发挥作用。这个选项通过强制要求源码中写下的 import/require 与最终输出的 import/require 一致来工作。
任何从同一源文件同时生成 ESM 和 CJS 两套输出的配置,从根本上都与 verbatimModuleSyntax 不兼容,因为这个选项的目的就是:凡是最终会输出 require 的地方,都不允许写 import。如果把第三方生成工具配置成输出与 tsc 不同的模块种类,也会破坏 verbatimModuleSyntax 提供的保证。例如,在 tsconfig.json 中设置 "module": "esnext",却把 Babel 配置为输出 CommonJS。返回正文
译注:版本与使用边界
- 这是滚动更新的官方指南,不是绑定某个项目版本的配置承诺。上文保留原文的配置选择与限定条件;
customConditions等选项仍需结合具体打包器文档确定。 - 官方 module 选项参考注明,
node18从 TypeScript 5.8 起可用;nodenext从 4.7 起可用,但会随最新稳定版 Node.js 的行为变化,并隐含浮动的target: "esnext"。旧编译器不一定接受原文库模板中的node18。 - 上述含注释或尾逗号的配置按
tsconfig.json可接受的 JSONC 形式展示,不应误当作严格 JSON。涉及./empty-file.ts的示例以该空文件存在为前提;.js后缀指向实际 JavaScript 输出。如果源文件是utils/index.ts,对应路径应按产物写为./utils/index.js。 target与默认lib有关联,但它们不会替运行环境安装 API 或补丁;如果手动覆盖lib,仍需自行匹配目标环境。浏览器示例中的paths用来帮助 TypeScript 找类型,不会替浏览器配置导入映射,也不是远端代码安全保证。- 原文引用的 tsx 仓库现跳转至
privatenumber/tsx,本译稿链接已跟随该目标。原文“静态分析”的 npm 页面本次返回 403,因此正文链接改用已核对的同项目 CLI 官方 README;工具用途不变。 - 代码、诊断和 CDN 地址仅作为文档内容展示。本文没有下载 CDN 模块,没有运行示例,也没有对任何实际项目作出“测试通过”的结论。











暂无评论内容