TypeScript tsconfig.json 配置完全指南:编译器选项、路径别名与工程化实践
2026/9/10 0:15:12 网站建设 项目流程

前阵子帮同事排查一个 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_modules
files显式列出要编译的文件文件很少且明确时用
extends继承另一份配置多环境配置、配置复用
references声明项目引用monorepo、增量构建
watchOptions控制监听模式的行为调试 watch 模式时偶尔用

compilerOptions 是绝对的主角,几十个子选项全塞在里面。剩下的顶层字段里,include 和 exclude 管的是"哪些文件进编译范围",extends 管的是"配置怎么复用",references 管的是"多个子项目怎么协作"。这些字段的关系得先理清楚,否则后面看任何配置示例都会觉得乱。

2. 编译目标与模块体系:为什么"编译过了"不等于"跑得对"

2.1 target 和 lib:一个管语法降级,一个管类型声明

target 可能是 tsconfig 里最直观的选项了:"target": "ES2020"意思就是编译产物按 ES2020 的标准来输出。它管的是语法层面的降级,比如你用async/await,target 设为 ES5 时,TypeScript 会把它降级成生成器函数;target 设为 ES2017 以上,就原样保留。语法降级是 tsc 自己就能干的事,但 API 层面的问题它管不了。PromiseMapArray.prototype.includes这些是运行时 API,需要运行环境本身支持,或者靠 polyfill。tsc 只负责在类型层面告诉你"你用了但环境里可能没有"。

这就要说到 lib 了。lib 字段控制的是编译时加载哪些标准库类型声明,默认值跟 target 联动:target 越高,默认加载的 lib 越新。一旦你手动写了 lib,就完全覆盖默认值。常见的坑是这样的:

{ "compilerOptions": { "target": "ES5", "lib": ["ES5", "DOM"] } }

这段配置里你手动指定了 lib,但没包含ES2015.Promise,于是代码里一用Promise就报"找不到 Promise 的类型声明"。类型报错不代表运行时一定没有 Promise,Node 12 以上早就有 Promise 了,但类型层面就是过不去。我见过不少项目为了兼容老浏览器把 target 压得很低,然后 lib 又漏配,最后只能靠@types或者额外加 lib 项来补。更合理的思路是:现代工程里浏览器代码都走打包器转译,target 没必要设得太低,Node 服务端代码直接看自己跑的 Node 版本,比如 Node 18 起步就设 ES2022。让 target 和实际运行环境保持接近,编译产物更干净,类型检查也更准。

2.2 module 与 moduleResolution:模块体系里最容易翻车的一对

module 决定编译产物使用哪种模块语法,moduleResolution 决定 TypeScript 在解析import语句时按什么规则去找文件。这俩必须搭配着来,官方在选项注释里就直接写了"module 和 moduleResolution 要成对使用"。

先看 module 的常见选项:commonjsesnextnode16/nodenextpreserve。如果工程是给 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 一般要配esnextpreserve

我用这个速查表帮不少同事快速定位过问题:

场景modulemoduleResolution
tsc 产出 CJS,Node 直接跑commonjsnode10
Node 原生 ESM,tsc 产出nodenextnodenext
代码交给 Vite/webpack 打包esnextbundler
纯浏览器直出,不用打包器esnextbundler 或 node10

2.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 是价值最高的一项,它把nullundefined正式纳入类型系统,string类型的变量不能直接赋null,调用一个可能为undefined的值时也必须先做判空。这会让你的代码多写很多判断,但它能在编译阶段拦住海量的运行时崩溃。strictFunctionTypes 对函数参数做了逆变检查,防止你把一个接收更宽泛参数的函数当成接收更窄参数的函数去用。strictBindCallApply 让bind/call/apply的参数也接受类型校验,strictPropertyInitialization 则要求在构造函数里把所有声明了的实例属性都初始化,否则报错。alwaysStrict 只是让产物自动带上"use strict",副作用最小。useUnknownInCatchVariables 把 catch 子句里的变量从 any 改成 unknown,逼你先做类型收窄再操作,比较新的版本已经并入 strict 了。

刚接触这些的人会觉得"这也太严格了,写起来处处受绊"。但我的亲身体会是:strict 模式的痛苦集中在改造存量代码的前两周,一旦熬过去,后面大部分类型错误都是自己写错了,编译器在替你挡子弹。新项目直接 strict,几乎没有理由不开。

3.2 两个容易被忽略的严苛选项:noUncheckedIndexedAccess 与 exactOptionalPropertyTypes

strict 全家桶之外还有两个不算 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.0,paths不依赖 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 的补充缩小。

默认情况下,如果没写 include,tsc 会编译当前目录及子目录下所有 .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 -b,TypeScript 会按引用关系自动先构建被依赖的项目,并缓存构建结果,第二次构建时只重建有变化的部分,这就是增量构建。对于代码量大的 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 里按Ctrl+Shift+P,输入TypeScript: Restart TS server,重启一下就好。命令行里的tsc --watch也一样,某些情况下改了 tsconfig 不一定触发重新加载,干脆停掉进程重新跑一次。这个"重启大法"听起来很笨,但就是管用。项目里我还遇到过一种情况:根目录有 tsconfig.json,子目录又有子配置,编辑器打开的某个文件到底用的是哪份配置,取决于它离哪份 tsconfig 最近。搞不清的时候,看 VS Code 右下角的 TypeScript 版本号旁边,或者用前面说的 --showConfig 先确认。

6.3 高频报错与配置修复速查表

把几年里遇到最多的配置相关报错整理成一张表,遇到问题直接对照:

报错信息根本原因配置修复方向
TS2307: Cannot find module '@/xxx'paths 没生效,或运行时别名没配检查 tsconfig paths、打包器 alias
Option 'baseUrl' is deprecated...正在使用 baseUrl删掉 baseUrl,paths 改相对路径
TS6059: File is not under 'rootDir'rootDir 设得太窄,文件跑出范围调整 rootDir 到公共父目录
TS1259: Module can only be default-imported...CommonJS 库的默认导入被拦开启 esModuleInterop
TS2686: 'React' refers to a UMD globalReact 被当成全局但当前是模块检查 jsx 配置与 @types/react
TS18003: 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 能看出工程化的成熟度。多数时候,项目里那些奇怪的编译问题,追到源头都是配置和实际运行方式不一致造成的。把这个文件读懂,很多问题根本不用去搜——你已经在编译器的角度想问题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询