TypeScript tsconfig.json 配置完全指南:编译器选项、路径别名与工程化实践

TypeScript tsconfig.json 配置完全指南:编译器选项、路径别名与工程化实践 前阵子帮同事排查一个 CI 上的诡异失败本地npm run dev一切正常代码一提交到流水线就开始报错而且不是业务代码的问题编译日志里夹着一行警告——Option baseUrl is deprecated and will stop functioning in TypeScript 7.0.。项目里那份 tsconfig.json 从创建那天起就没怎么动过谁也没想到是它埋的雷。其实这不是个例。绝大多数 TypeScript 项目的 tsconfig.json 都是脚手架顺手生成的生成完就再没人打开过第二次。而这个文件恰恰是整个项目类型系统的总控台编译目标、模块解析方式、类型检查的严苛程度、哪些文件参与编译、路径别名怎么解析全由它说了算。这篇东西我不按官方文档的目录顺序讲而是按自己这几年接手各种项目后最常被问到、最常出问题的点来拆适合刚学 TypeScript 想弄懂配置的人也适合那些写了两三年 TS 却从没逐项读过 tsconfig 的老手。1. 先弄明白 tsconfig.json 在工程里到底是什么角色1.1 编译器是从哪个目录开始找这份配置的很多人对 tsconfig.json 的第一印象是项目根目录下的一份 JSON 文件但它的作用机制比想象中更微妙。当你直接执行tsc而不带任何参数时编译器会从当前工作目录开始逐级向上查找 tsconfig.json直到找到为止。换句话说你在子目录里运行tsc它认的仍然是项目根目录那份配置。这也是很多新人第一次感到困惑的地方明明我在src/utils底下执行命令怎么编译结果跟我在根目录跑一模一样因为它压根没在子目录找配置而是向上找到了根目录的 tsconfig.json。如果你用tsc -p tsconfig.app.json这种方式显式指定配置文件那编译器就直接以这份文件为基准不再向上探测。-p后面既可以跟具体文件名也可以跟一个包含 tsconfig.json 的目录。这个参数在 monorepo 里特别有用后面讲项目引用时会再提到。还有一点容易忽略如果直接执行tsc someFile.ts编译器会绕过 tsconfig.json用一套默认选项单独编译这个文件。默认选项基本等于没有任何类型检查加成的最朴素状态strict 不开、模块解析方式也是最老的。所以当你在命令行里手动指定文件编译时遇到的行为和项目里实际构建时的行为不一致别惊讶它俩走的根本不是同一套配置。1.2 配置文件的顶层字段地图谁管类型谁管文件谁管构建tsconfig.json 虽然是个 JSON但它的顶层字段各司其职总共没几个。我先把地图画出来后面再逐个展开顶层字段作用典型场景compilerOptions控制编译与类型检查行为最核心几乎天天跟它打交道include指定参与编译的文件范围用 glob 模式圈定 src 等目录exclude排除某些文件不参与编译排除 tests、dist、node_modulesfiles显式列出要编译的文件文件很少且明确时用extends继承另一份配置多环境配置、配置复用references声明项目引用monorepo、增量构建watchOptions控制监听模式的行为调试 watch 模式时偶尔用compilerOptions 是绝对的主角几十个子选项全塞在里面。剩下的顶层字段里include 和 exclude 管的是哪些文件进编译范围extends 管的是配置怎么复用references 管的是多个子项目怎么协作。这些字段的关系得先理清楚否则后面看任何配置示例都会觉得乱。2. 编译目标与模块体系为什么编译过了不等于跑得对2.1 target 和 lib一个管语法降级一个管类型声明target 可能是 tsconfig 里最直观的选项了target: ES2020意思就是编译产物按 ES2020 的标准来输出。它管的是语法层面的降级比如你用async/awaittarget 设为 ES5 时TypeScript 会把它降级成生成器函数target 设为 ES2017 以上就原样保留。语法降级是 tsc 自己就能干的事但 API 层面的问题它管不了。Promise、Map、Array.prototype.includes这些是运行时 API需要运行环境本身支持或者靠 polyfill。tsc 只负责在类型层面告诉你你用了但环境里可能没有。这就要说到 lib 了。lib 字段控制的是编译时加载哪些标准库类型声明默认值跟 target 联动target 越高默认加载的 lib 越新。一旦你手动写了 lib就完全覆盖默认值。常见的坑是这样的{ compilerOptions: { target: ES5, lib: [ES5, DOM] } }这段配置里你手动指定了 lib但没包含ES2015.Promise于是代码里一用Promise就报找不到 Promise 的类型声明。类型报错不代表运行时一定没有 PromiseNode 12 以上早就有 Promise 了但类型层面就是过不去。我见过不少项目为了兼容老浏览器把 target 压得很低然后 lib 又漏配最后只能靠types或者额外加 lib 项来补。更合理的思路是现代工程里浏览器代码都走打包器转译target 没必要设得太低Node 服务端代码直接看自己跑的 Node 版本比如 Node 18 起步就设 ES2022。让 target 和实际运行环境保持接近编译产物更干净类型检查也更准。2.2 module 与 moduleResolution模块体系里最容易翻车的一对module 决定编译产物使用哪种模块语法moduleResolution 决定 TypeScript 在解析import语句时按什么规则去找文件。这俩必须搭配着来官方在选项注释里就直接写了module 和 moduleResolution 要成对使用。先看 module 的常见选项commonjs、esnext、node16/nodenext、preserve。如果工程是给 Node 用并且由 tsc 直接产出 CommonJS 代码module: commonjs就行。如果代码最终交给 Vite、webpack、esbuild 这类打包器处理那 module 设成esnext最合适因为打包器本身就能处理 ESM不需要 tsc 帮你转成 require。nodenext是在 Node 原生支持 ESM 之后新增的模式它要求你尊重 package.json 里的type字段type: module时 .ts 文件按 ESM 处理否则按 CJS 处理。这个模式比commonjs更高级但代价是你得适应 Node 的原生 ESM 规则比如 ESM 下 import 必须带文件扩展名。moduleResolution 的坑比 module 更多。老一点的配置里常见moduleResolution: node这个值后来被改名为node10代表的是 Node 经典的解析规则先找 exact 文件名再补后缀再找 index.ts然后一级一级往 node_modules 里翻。如果你的项目要用 package.json 的exports字段来控制包入口node10是不认的这时候就得用nodenext要求 module 也是nodenext。而moduleResolution: bundler是 TypeScript 5.0 专门为打包器场景加的它模拟 webpack 这类工具的行为既认 exports 字段又不强制要求 ESM 下写文件扩展名。选bundler时module 一般要配esnext或preserve。我用这个速查表帮不少同事快速定位过问题场景modulemoduleResolutiontsc 产出 CJSNode 直接跑commonjsnode10Node 原生 ESMtsc 产出nodenextnodenext代码交给 Vite/webpack 打包esnextbundler纯浏览器直出不用打包器esnextbundler 或 node102.3 esModuleInterop为什么import React from react能过、换成别的不行esModuleInterop 是新手最容易忽略、老手也经常说不清的一个开关。它的作用一句话讲是让 ESM 的默认导入语法能正确对接 CommonJS 模块。在它没开启时你import React from react如果 React 的类型声明用的是export React这种写法TypeScript 会直接报错提示你只能用import * as React from react。开了 esModuleInterop 之后tsc 会在编译产物里插入__importDefault这样的辅助函数把 CommonJS 的module.exports包一层再拿 default两边就都顺畅了。这个选项还隐含开启了allowSyntheticDefaultImports后者只影响类型检查不改变产物专门用来假装某些没有默认导出的模块可以当成默认导出导入。实际工作中我几乎没见过需要关掉 esModuleInterop 的项目开着它、配合module: commonjs是绝大多数 Node 后端工程的标准配置。如果哪天你发现某个库的默认导入在编辑器里飘红先检查一下 esModuleInterop 是不是被谁不小心关了。3. 严格模式不是让你受罪逐项拆开 strict 全家桶3.1 strict 一个开关背后到底开了什么如果你问一个 TypeScript 老手新项目 tsconfig 第一行配什么大概率是strict: true。这个开关一打开等于同时开启了以下七个子检查项noImplicitAny、strictNullChecks、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitThis、alwaysStrict以及后来加进去的 useUnknownInCatchVariables。每项都有具体含义。noImplicitAny 负责拦截参数没有类型、TypeScript 猜不出来就默认当 any的情况它逼着你把类型写清楚是代码可维护性的第一道防线。strictNullChecks 是价值最高的一项它把null和undefined正式纳入类型系统string类型的变量不能直接赋null调用一个可能为undefined的值时也必须先做判空。这会让你的代码多写很多判断但它能在编译阶段拦住海量的运行时崩溃。strictFunctionTypes 对函数参数做了逆变检查防止你把一个接收更宽泛参数的函数当成接收更窄参数的函数去用。strictBindCallApply 让bind/call/apply的参数也接受类型校验strictPropertyInitialization 则要求在构造函数里把所有声明了的实例属性都初始化否则报错。alwaysStrict 只是让产物自动带上use strict副作用最小。useUnknownInCatchVariables 把 catch 子句里的变量从 any 改成 unknown逼你先做类型收窄再操作比较新的版本已经并入 strict 了。刚接触这些的人会觉得这也太严格了写起来处处受绊。但我的亲身体会是strict 模式的痛苦集中在改造存量代码的前两周一旦熬过去后面大部分类型错误都是自己写错了编译器在替你挡子弹。新项目直接 strict几乎没有理由不开。3.2 两个容易被忽略的严苛选项noUncheckedIndexedAccess 与 exactOptionalPropertyTypesstrict 全家桶之外还有两个不算 strict 成员、但严格程度更进一步的选项我建议有精力的人关注一下。noUncheckedIndexedAccess开启后通过索引拿到的值类型会带上undefined。比如arr[i]的类型从T变成T | undefinedobj[key]同样。刚开这个选项时你会觉得代码里到处都是红色感叹号很多数组遍历逻辑都要加守卫。但这也正是它想逼你做的事索引访问本来就可能越界凭什么类型系统假装它一定存在这个选项在生产项目中会让代码健壮不少代价是开发时确实烦躁。exactOptionalPropertyTypes则是另外一码事。它把可选属性和显式赋 undefined区分开来。foo?: string表示这个属性可以不存在但不代表你可以把它显式赋值为 undefined。开启后那些obj.foo undefined的写法会被报错必须改成delete obj.foo或者重新设计类型。这个选项对类型洁癖患者很友好但对习惯了宽松写法的团队来说会觉得 TypeScript 在吹毛求疵。两个选项默认都关着我建议先在开发分支上打开跑一遍把报错清理完再合并一次性能揪出很多隐藏问题。3.3 关闭某些检查项的正确姿势而不是一关了之strict 是好东西但现实项目里难免有必须妥协的时候。常见的错误做法是遇到搞不定的报错就全局关掉对应选项或者直接在报错行上面写// ts-ignore。ts-ignore的问题是它不分青红皂白地吞掉下一行的所有报错之后真正的错误也会被它掩盖。更推荐用的是// ts-expect-error它表示我预期下一行有错如果没错反而会提醒我。这个语义在清理历史遗留代码时特别好用你预期这里有类型问题先用 ts-expect-error 兜住等将来类型修好了它自己会暴露出来提醒你删掉注释。全局层面如果某个旧包的类型声明确实烂到没法用可以考虑给这个包所在的目录单独配一份 tsconfig 或者用skipLibCheck跳过声明文件的类型检查。skipLibCheck 让 tsc 不检查 .d.ts 文件内部的类型一致性只检查它们对外暴露的接口是否被正确使用这个选项能显著加快巨型项目的编译速度算是一个有性价比的妥协。它也被默认加到了很多脚手架里不用觉得开它是什么丢人的事。4. baseUrl 废弃了路径别名迁移的正确姿势与配套改造4.1 为什么官方突然要废掉 baseUrl把 baseUrl 单独拎出来讲是因为这段时间不少人的编译日志里出现了文章开头那句提醒Option baseUrl is deprecated and will stop functioning in TypeScript 7.0。这其实是 TypeScript 官方在给一个历史包袱做清理。baseUrl 当初的作用是给paths里的路径别名提供一个相对基准。老写法是这样的{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }意思是所有/xxx的导入都相对 baseUrl也就是项目根目录去src/xxx找文件。看起来挺合理但问题在于 baseUrl 还隐含了另一个行为设置了它之后所有非相对路径的模块导入会以 baseUrl 作为第一级查找起点。比如你写import { foo } from utils/helper在 baseUrl 存在时tsc 会先去项目根/utils/helper里找找不到再去 node_modules 里找。这个行为让很多项目悄悄依赖上了用 baseUrl 把任意目录当模块根目录的写法而这类写法在 Node 原生 ESM 环境下根本不成立因为真正的模块解析不可能把你项目里的某个目录当成全局模块目录。所以从 TypeScript 4.1 开始官方就支持了不写 baseUrl、只写 paths的用法paths 里的路径可以相对配置文件自身解析。到了 5.0paths不依赖 baseUrl 已经是很成熟的能力了。现在官方决定在 7.0 彻底移除 baseUrl意思很明确路径别名请用 paths 自己解决别再靠那个容易误导人的全局基准了。4.2 迁移步骤把 baseUrl 从配置里安全摘掉迁移这个其实不复杂但有个细节必须注意删掉 baseUrl 之后paths 里的相对写法要改成相对配置文件所在目录。以前的写法{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }改后{ compilerOptions: { paths: { /*: [./src/*] } } }区别就在路径数组里的字符串多了./前缀。因为不再有 baseUrl 做基准paths 的值默认相对 tsconfig.json 所在目录解析所以./src/*才是正确指向。如果之前项目里有人依赖baseUrl: src写了一批不带别名的相对项目根的导入比如import { a } from components/Button这类写法在删除 baseUrl 后会直接解析失败必须改成./src/components/Button或者统一换成 paths 别名。迁移时先在代码里全局搜一下哪些导入路径不是相对路径也不是 node_modules 包名这些就是潜在的重灾区。改完之后跑tsc --noEmit把所有 TS2307找不到模块清干净基本就迁移完成了。4.3 光改 tsconfig 不够打包器和运行时的路径解析要跟着改路径别名领域最大的误区就是以为改完 tsconfig 里 paths 就万事大吉。tsconfig 里的 paths 只负责让 tsc 的类型检查认得这个别名真正运行时模块能不能被找到取决于打包器或者 Node 运行时的解析规则。这是两个完全独立的体系很多人分不清。如果你用 Vite需要在 vite.config.ts 里配import { fileURLToPath, URL } from node:url; export default defineConfig({ resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } });webpack 则是在 resolve.alias 里写module.exports { resolve: { alias: { : path.resolve(__dirname, src) } } };测试框架也要各自配。Vitest 会读 Vite 的 alias但 Jest 需要单独在 moduleNameMapper 里映射moduleNameMapper: { ^/(.*)$: rootDir/src/$1 }Node 直接跑 ts-node 或 tsx 的话还得装 tsconfig-paths 或者用 tsx 自带的 paths 支持。总之一句话tsconfig 里的 paths 是类型层面的地图打包器和运行时各自还要有一份属于自己的地图。两边不一致的典型症状就是 tsc 编译通过、typescript-eslint 也不报错但一跑起来就报 Cannot find module /xxx排错时优先怀疑别名没有配套配置。5. include、exclude、extends文件边界和配置复用的那些暗坑5.1 include 和 exclude 到底是怎么工作的include 和 exclude 看起来简单实际规则里有个容易忽略的点exclude 只对 include 圈定的范围起作用如果某个文件是通过files字段显式列出来的exclude 拦不住它同样的exclude 也拦不住被别处 import 进来、由模块依赖链引入的文件。很多人把这个机制理解成exclude 是安全围栏把不想编译的文件挡在外面其实它不是include 才是真正决定编译入口的围栏exclude 只是对 include 的补充缩小。默认情况下如果没写 includetsc 会编译当前目录及子目录下所有 .ts 文件node_modules、bower_components、jspm_packages 以及 outDir 输出目录默认被排除。最常见的坑就是把编译产物目录比如 dist放在项目根目录下结果每次编译后 tsc 可能把上次编译出来的一堆 .d.ts 和 .js 文件又当成输入扫进去了循环污染。所以新项目我建议一上来就显式写 include{ include: [src], exclude: [src/**/*.test.ts, node_modules, dist] }include 支持 glob 模式src/**/*表示 src 下所有子目录的所有文件。测试文件单独排除或者单独用一份 tsconfig 管是大型项目常见的做法。5.2 extends 继承配置时最隐蔽的一个坑extends 让配置可以复用基础配置比如一个 tsconfig.base.json 统一管 compilerOptions各子项目各自 extends 再加自己的 include。这个机制在 monorepo 里几乎是标配但有个行为很多人不知道被继承的配置文件里出现的相对路径不是相对子配置文件解析而是相对那个被继承文件自己所在的目录解析。举个例子packages/shared/tsconfig.base.json里写了outDir: dist然后packages/app/tsconfig.json通过extends: ../shared/tsconfig.base.json继承它。那么最终 outDir 会指向packages/shared/dist而不是你以为的packages/app/dist。因为路径在解析时已经相对 base 文件目录计算好了。这个规则适用于 outDir、rootDir、include 等所有带路径语义的字段。如果基础配置统一放在仓库根目录那问题不大一旦把基础配置放在某个子包里后续人很容易在这个上面消耗半天。另一个注意点是 extends 的覆盖逻辑不是深度合并。compilerOptions 里的某个子对象比如 paths在子配置里重新声明时是整体替换而不是逐项追加。如果你期待基础配置里有两个 paths 映射子配置再加一个最后是三个合并在一起那会大失所望。实际结果是子配置的 paths 覆盖了基础配置的 paths只剩下子配置里写的那些。这个行为在官方文档里写得比较含蓄但实测无数人踩过。5.3 项目引用monorepo 里更专业的协作方式当仓库里多个包相互依赖、又希望各自独立编译时单靠 extends 已经不够了项目引用references是更正规的方案。它需要被引用的项目在自己 tsconfig 里开composite: true。composite 模式的约束很多必须显式指定 include、必须设 declaration通常会强制 declaration 为 true、rootDir 也有要求。总之就是逼你把一个项目当作可独立构建的单元来组织。用法很简单{ references: [ { path: ../shared }, { path: ../utils } ] }然后在仓库根执行tsc -bTypeScript 会按引用关系自动先构建被依赖的项目并缓存构建结果第二次构建时只重建有变化的部分这就是增量构建。对于代码量大的 monorepo这个能力能把构建时间从分钟级降到秒级。代价是配置约束多、心智负担重项目没大到那个份上先用 extends 就够了别为了炫技引入 references。6. 调试配置时的救命工具与高频报错速查6.1 两个命令看清真实的配置和文件清单extends 用多了之后经常出现我在这份 tsconfig 里改了字段但实际生效没有的困惑。这时候别靠肉眼推理直接让编译器把合并后的配置打出来npx tsc --showConfig这个命令会输出一份完整的、继承链合并完毕的配置 JSON一眼就能看出当前生效的 compilerOptions 到底是什么。改配置后不确定有没有覆盖成功跑一下这个命令比看任何编辑器提示都直接。确认完配置接下来要回答我的 src 目录下为什么有个奇怪的文件被编译了用npx tsc --explainFiles它会列出所有参与编译的文件并标注每个文件是被哪个 include 模式匹配进来的、或者被哪个 import 依赖拉进来的。想排查某个 .d.ts 是不是因为 types 自动引入而参与编译这个命令一目了然。再配合--traceResolution能把每一次模块导入的完整查找路径打印出来是解决模块找不到类问题的大杀器就是输出极其啰嗦建议配合 grep 使用。6.2 改了配置没反应先想起重置 TypeScript Server这是一个非常实际、又特别容易被忽略的问题。你在 tsconfig.json 里加了一个 paths 映射编辑器里的错误却迟迟不消失或者你刚改了strict满屏报错却纹丝不动。这时候别怀疑自己改错了大概率是编辑器里的 TypeScript Server 还在用旧的配置。VS Code 里按CtrlShiftP输入TypeScript: Restart TS server重启一下就好。命令行里的tsc --watch也一样某些情况下改了 tsconfig 不一定触发重新加载干脆停掉进程重新跑一次。这个重启大法听起来很笨但就是管用。项目里我还遇到过一种情况根目录有 tsconfig.json子目录又有子配置编辑器打开的某个文件到底用的是哪份配置取决于它离哪份 tsconfig 最近。搞不清的时候看 VS Code 右下角的 TypeScript 版本号旁边或者用前面说的 --showConfig 先确认。6.3 高频报错与配置修复速查表把几年里遇到最多的配置相关报错整理成一张表遇到问题直接对照报错信息根本原因配置修复方向TS2307: Cannot find module /xxxpaths 没生效或运行时别名没配检查 tsconfig paths、打包器 aliasOption baseUrl is deprecated...正在使用 baseUrl删掉 baseUrlpaths 改相对路径TS6059: File is not under rootDirrootDir 设得太窄文件跑出范围调整 rootDir 到公共父目录TS1259: Module can only be default-imported...CommonJS 库的默认导入被拦开启 esModuleInteropTS2686: React refers to a UMD globalReact 被当成全局但当前是模块检查 jsx 配置与 types/reactTS18003: No inputs were found in config fileinclude/rootDir 指到了空目录检查 include 路径是否正确TS6307: File is not listed within the file list of project项目引用下文件归属不清检查 references 与 include 边界这几个报错的共同特点是报错文本跟根因之间隔着一层配置光看报错可能完全摸不着头脑。我的排查顺序永远是先看配置再改代码因为这类问题改代码是改不对的。最后分享一点个人习惯。新到一个项目组我第一件事不是看 README而是先把 tsconfig.json 从头到尾读一遍。这份文件基本上就是这个项目的性格说明书target 能看出它对运行环境的认知strict 开没开能看出团队对类型纪律的态度paths 能看出目录结构的组织思路references 能看出工程化的成熟度。多数时候项目里那些奇怪的编译问题追到源头都是配置和实际运行方式不一致造成的。把这个文件读懂很多问题根本不用去搜——你已经在编译器的角度想问题了。