1. 为什么 tsconfig.json 值得你花时间吃透
如果你写过一段时间 TypeScript,大概率经历过这样的场景:项目跑得好好的,某天加了个新目录,编辑器突然满屏红波浪线;或者本地tsc编译一切正常,CI 上却报了一堆类型错误;再或者import路径写起来像迷宫,../../../数到眼花。这些问题十有八九都指向同一个地方——tsconfig.json。
tsconfig.json是 TypeScript 项目的“总控台”。它决定了哪些文件参与编译、编译成什么目标、模块怎么解析、类型检查有多严格、路径别名怎么配、增量构建缓存放哪。很多人对这个文件的态度是“脚手架生成啥就用啥”,能跑就不动。但一旦项目规模上来,或者要接入构建工具、要发布 npm 包、要做 monorepo,这个文件里每一个选项都会变成你必须理解的东西。
这篇文章面向所有正在用 TypeScript 的人——不管你是刚接触tsc的新手,还是已经能背出strict系列选项的老手。我会把tsconfig.json从整体结构到核心字段逐个拆开讲清楚,解释每个选项背后的设计意图,给出可以直接抄的配置模板,再分享一些我在实际项目里踩过的坑。读完你至少能做到:看到任何一个tsconfig.json都能读懂它在干什么,并且能根据自己的项目需求改出合适的配置。
需要先说明一点:TypeScript 版本迭代很快,部分选项在不同版本间有弃用和迁移。比如baseUrl在较新版本中已经被标记为弃用,官方建议用paths配合其他方式替代;moduleResolution: node10同样进入了弃用通道。这些变化我会在对应章节里点出来,避免你照着老教程配完发现一堆警告。
2. tsconfig.json 的整体结构与加载逻辑
2.1 这个文件到底长什么样
从结构上看,tsconfig.json就是一个 JSON 文件,根层级只有几个顶层字段,最核心的是compilerOptions,其余是描述“编译哪些文件”的字段。一个最小可用的配置大概是这样:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "strict": true }, "include": ["src/**/*"] }顶层字段主要有这几个:
compilerOptions:编译器选项,99% 的配置都写在这里。include:指定要纳入编译的文件 glob 模式。exclude:排除哪些文件,默认会排除node_modules、bower_components、jspm_packages和outDir。files:精确列出要编译的文件,适合文件极少的场景,一般不用。extends:继承另一个配置文件,monorepo 和分层配置的基石。references:项目引用,用于把大项目拆成多个可独立编译的子项目。compileOnSave:老编辑器时代的产物,现在基本被语言服务取代,可以忽略。
理解这几个字段的优先级很重要。files的优先级最高,它列出的文件一定会被编译;include和exclude是一对,include决定候选集合,exclude从中剔除;如果两者都没写,默认编译当前目录及子目录下所有.ts、.tsx、.d.ts文件。这里有个容易踩的坑:exclude只对include生效,如果你用files显式列了某个文件,即使它在exclude里也照样会被编译。
2.2 配置是怎么被找到和合并的
当你运行tsc而不指定文件时,编译器会从当前目录开始向上逐级查找tsconfig.json,找到第一个就用它。你也可以用tsc -p ./some/path/tsconfig.build.json显式指定。这个“向上查找”的行为在多包仓库里特别容易出问题——子目录里跑tsc可能意外用了根目录的配置。
extends的合并规则值得单独说。它做的是“浅合并”加“覆盖”:子配置里出现的字段会整体覆盖父配置的同名字段,而不是深度合并。举个例子,父配置compilerOptions.paths里有两个别名,子配置只写了一个,那结果就是子配置那一个,父配置的两个不会保留。这一点和很多人直觉里的“合并”不一样,我见过有人因此丢了路径别名排查半天。
extends的路径解析也有讲究。如果写的是相对路径,相对于当前配置文件所在目录解析;如果写的是包名(比如@tsconfig/node20/tsconfig.json),则按 Node 模块解析规则去找。社区里有一批官方维护的基础配置包,直接继承能省不少事。
2.3 为什么建议把配置分层
在中大型项目里,我强烈建议把配置拆成至少两层:一个tsconfig.base.json放通用规则,一个tsconfig.json放项目特定规则。这样做的好处是,当你有多个子项目(比如前端、后端、测试)时,公共部分只维护一份,改一处全生效。
更进一步,构建产物和类型检查其实可以用不同的配置。类型检查要覆盖测试文件、要开最严格的检查;构建产物只需要源码、要生成声明文件、要去掉测试。用tsconfig.json做类型检查、tsconfig.build.json做构建,是很多库项目的标准做法。tsconfig.build.json里通常写"extends": "./tsconfig.json"然后"exclude": ["**/*.test.ts", "**/*.spec.ts"]。
3. compilerOptions 核心选项逐个拆解
3.1 target、lib 与 module:决定输出形态的三兄弟
target决定编译后代码的语法版本,可选值从ES3一直到ESNext。它影响的是语法降级:比如你写async/await,target是ES5时会被编译成__awaiter辅助函数,target是ES2017及以上则原样保留。选target的原则很简单:看你的运行环境支持到哪。现代 Node 和现代浏览器基本都支持ES2020以上,没必要为了兼容远古环境把代码降级得面目全非。
lib决定编译时能用的内置类型声明,比如DOM、ES2020、WebWorker。它和target是解耦的:target管语法,lib管类型。一个常见误区是只改target不改lib,结果想用Promise.allSettled却提示不存在。稳妥的做法是让lib至少包含target对应的 ES 版本,比如target: ES2020就配lib: ["ES2020", "DOM"]。如果是纯 Node 项目,把DOM去掉,避免误用浏览器 API 却编译通过。
module决定输出什么模块格式,可选CommonJS、ESNext、NodeNext、Preserve等。这里有个关键点:module和moduleResolution是配套的。用NodeNext时,moduleResolution也应该是NodeNext,它会根据package.json的type字段和文件扩展名来决定模块解析方式。如果你在写 ESM 的 Node 项目,module: NodeNext基本是唯一正确选择。
3.2 strict 家族:类型安全的开关组
strict: true不是一个选项,而是一组选项的总开关。打开它等于同时打开了下面这一串:
| 选项 | 作用 | 典型影响 |
|---|---|---|
noImplicitAny | 禁止隐式 any | 未标注类型的参数会报错 |
strictNullChecks | null/undefined 独立类型 | 必须显式处理可能为空的值 |
strictFunctionTypes | 函数参数逆变检查 | 回调类型不兼容会报错 |
strictBindCallApply | 校验 bind/call/apply | 参数类型不匹配会报错 |
strictPropertyInitialization | 类属性必须初始化 | 构造函数里没赋值会报错 |
noImplicitThis | 禁止隐式 this | this 类型不明确会报错 |
alwaysStrict | 输出严格模式 | 每个文件加 "use strict" |
useUnknownInCatchVariables | catch 变量为 unknown | catch 里不能直接当 any 用 |
新项目我建议无脑开strict。老项目迁移时如果一次性开报错太多,可以逐个打开,先开strictNullChecks和noImplicitAny这两个收益最大的。strictNullChecks尤其重要,它逼着你处理空值,能消灭大量运行时的Cannot read property of undefined。
除了strict家族,还有几个强烈建议开的检查:noUnusedLocals和noUnusedParameters能揪出没用的变量和参数,noFallthroughCasesInSwitch防止 switch 漏写 break,noImplicitReturns要求所有分支都有返回值。这些在团队协作里能省下大量 review 时间。
3.3 moduleResolution 与 paths:模块解析的规则与捷径
moduleResolution决定 TypeScript 怎么找到import的模块。历史上主要有两种:node10(旧称node)和node16/nodenext。node10模拟的是老式 Node 的解析行为,不区分 ESM 和 CJS,也不强制文件扩展名。node16/nodenext则严格遵循现代 Node 的 ESM 规则,要求相对导入带扩展名。
这里要特别提醒:moduleResolution: node10已经被标记为弃用,官方计划在 TypeScript 7.0 中移除。如果你现在还在用它,建议尽早迁移到bundler或nodenext。bundler是给打包工具(Vite、webpack、esbuild)用的,它允许省略扩展名,行为和打包工具一致,是目前前端项目的主流选择。
paths是路径别名,配合baseUrl使用(注意baseUrl在新版本中已弃用,现在paths可以独立于baseUrl工作,路径相对于配置文件所在目录解析)。一个典型配置:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"], "@utils/*": ["./src/utils/*"] } } }配了paths之后,tsc能正确解析类型,但运行时不一定认识这些别名。这是新手最容易踩的坑:编辑器不报错,一跑就Cannot find module。原因是paths只影响类型检查,不影响运行时模块解析。解决办法是让运行时也认识别名——用打包工具的话在打包配置里配同样的 alias;纯 Node 环境可以用tsc-alias这类工具在编译后重写路径,或者干脆用 Node 的imports字段。
3.4 输出相关:outDir、rootDir 与 declaration
outDir指定编译产物输出目录,rootDir指定源码根目录。这两个要配合好,否则输出目录结构会乱。规则是:outDir里的目录结构由rootDir到各源文件的相对路径决定。如果不设rootDir,TypeScript 会取所有输入文件的公共父目录作为根,这可能导致输出结构和你预期不一致。
举个例子,源码在src/下,测试在test/下,如果两个都参与编译且不设rootDir,公共父目录是项目根,输出就会变成dist/src/...和dist/test/...。设了rootDir: "./src"之后,输出就是干净的dist/...。所以构建配置里通常会把测试排除掉,并显式设rootDir。
declaration: true生成.d.ts类型声明文件,发布 npm 包必须开。declarationMap: true生成声明文件的 source map,方便使用者跳转到源码。sourceMap: true生成 JS 的 source map,调试用。removeComments去掉注释,noEmit只做类型检查不输出文件——后者在“用打包工具构建、只用 tsc 检查类型”的项目里非常常用。
3.5 增量与性能:incremental、skipLibCheck 与 isolatedModules
incremental: true开启增量编译,TypeScript 会把上次编译的信息存到.tsbuildinfo文件里,下次只重新编译变化的部分。大项目里这个开关能显著缩短编译时间。配合tsBuildInfoFile可以指定缓存文件位置,建议把它放进outDir或专门的缓存目录,别污染项目根目录。
skipLibCheck: true跳过对.d.ts文件的类型检查。这个选项争议很大,但我的实践是:绝大多数项目都该开。原因是第三方库的声明文件质量参差不齐,检查它们经常报出你根本改不了的错误,白白浪费时间。跳过库检查不影响你自己代码的类型安全。
isolatedModules: true要求每个文件都能被独立编译,这对 Babel、esbuild、SWC 这类逐文件转译的工具很重要。开了它之后,const enum和某些类型的重新导出会受限。用现代打包工具的项目建议开启,能提前发现那些“tsc 能过但打包工具处理不了”的写法。
4. 不同场景下的配置模板与实操
4.1 现代前端项目(Vite + React)
前端项目现在基本是 Vite 的天下,配置上要照顾到打包工具的行为。下面这份是我常用的模板:
{ "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "isolatedModules": true, "skipLibCheck": true, "noEmit": true, "resolveJsonModule": true, "allowImportingTsExtensions": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src"] }几个关键点解释一下。moduleResolution: bundler让类型解析和 Vite 保持一致,允许省略扩展名。noEmit: true是因为构建交给 Vite,tsc只负责类型检查,通常配合tsc --noEmit作为 CI 的一步。allowImportingTsExtensions允许在 import 里写.ts/.tsx扩展名,这个选项要求noEmit或emitDeclarationOnly同时开启。jsx: react-jsx是 React 17 之后的新 JSX 转换,不需要再手动import React。
resolveJsonModule让你能直接import data from './data.json',Vite 和 webpack 都支持,配上类型解析才不报错。DOM.Iterable补上NodeList、HTMLCollection等的迭代器类型,遍历 DOM 集合时很有用。
4.2 Node 后端项目(ESM)
Node 项目现在越来越多用 ESM,配置和前端差别不小:
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "./dist", "rootDir": "./src", "declaration": true, "sourceMap": true, "incremental": true, "tsBuildInfoFile": "./dist/.tsbuildinfo", "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] }module: NodeNext要求相对导入必须带.js扩展名(即使源文件是.ts),这是 ESM 的硬性规则,很多人第一次遇到会懵。esModuleInterop: true让import fs from 'fs'这种默认导入能正常工作,不开的话得写import * as fs from 'fs'。forceConsistentCasingInFileNames强制文件名大小写一致,能避免在大小写不敏感的系统(macOS、Windows)上开发、在 Linux 上构建时出现的诡异问题。
tsBuildInfoFile我特意放进了dist,这样清理构建产物时缓存也一起清掉,不会出现“删了 dist 但缓存还在导致编译结果不对”的情况。
4.3 库项目(要发布 npm 包)
发布 npm 包对配置要求最高,因为你要同时产出 JS、类型声明,还要考虑使用者的各种环境:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src", "skipLibCheck": true, "isolatedModules": true, "verbatimModuleSyntax": true }, "include": ["src"], "exclude": ["**/*.test.ts", "**/*.spec.ts"] }verbatimModuleSyntax: true是个比较新的选项,它要求类型导入必须用import type显式标注,普通import不会被擦除。这样做的好处是编译结果更可预测,配合打包工具时不会出现“以为擦掉了结果留下了”的问题。declarationMap让使用者在编辑器里点进你的类型能跳到源码,体验好很多。
库项目通常还要产出多种模块格式(CJS + ESM),这靠tsc单次编译做不到,一般用打包工具或者跑两次tsc配不同module。如果跑两次,记得第二次的outDir和tsBuildInfoFile要区分开,否则缓存会互相覆盖。
4.4 monorepo 与项目引用
monorepo 里references是核心。假设你有packages/core和packages/app,app依赖core:
{ "compilerOptions": { "composite": true, "declaration": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src"], "references": [{ "path": "../core" }] }composite: true是项目引用的前提,它强制开启declaration,并要求所有源文件都在include范围内。被引用的项目必须先构建,tsc -b命令会按依赖顺序自动构建整个引用图。tsc -b --watch则能监听所有项目的变化增量重建,monorepo 开发体验靠它。
项目引用最大的价值是增量构建和边界清晰。每个包独立编译,改一个包只重建它和依赖它的包,不用全量编译。同时它强制包之间只能通过公开入口互相引用,避免了跨包直接 import 内部文件的混乱。
5. 常见问题与排查技巧实录
5.1 编辑器不报错但编译报错,或反过来
这是最高频的问题,根源通常是编辑器用的 TypeScript 版本和项目里的不一致。VS Code 默认用自带的 TS 版本,可能和你node_modules里的差好几个大版本。解决办法是在 VS Code 里执行 “TypeScript: Select TypeScript Version”,选 “Use Workspace Version”。团队里最好在.vscode/settings.json里固定这个设置,避免每个人环境不同。
另一个原因是编辑器读的配置和tsc读的不是同一个。比如你在子目录里跑tsc,它向上找到了根目录的配置,而编辑器用的是子目录的配置。排查方法是在报错文件所在目录跑tsc --showConfig,看看实际生效的配置是什么。
5.2 路径别名运行时找不到模块
前面提过,paths只管类型不管运行时。排查时先确认三件事:打包工具或运行时的 alias 配了没、paths的路径基准对不对、有没有baseUrl的历史遗留问题。baseUrl弃用后,paths的值相对于配置文件所在目录解析,如果你从老项目迁移过来,原来依赖baseUrl的相对路径可能要调整。
一个实用的调试技巧是用tsc --traceResolution打印模块解析的完整过程,它会告诉你 TypeScript 尝试了哪些路径、为什么没找到。输出很长,但配合 grep 过滤目标模块名,定位问题非常快。
5.3 编译产物目录结构不对
症状是dist里多了一层src,或者文件散落在意料之外的位置。根因基本都是rootDir没设或设错。记住那条规则:输出结构 = 源文件相对rootDir的路径。如果没设rootDir,TypeScript 取所有输入文件的公共父目录。所以当你发现多了一层,先检查是不是有include范围外的文件被拉进来了,把rootDir显式设成源码目录通常能解决。
还有一种情况是include用了过于宽泛的 glob,把配置文件、脚本文件也纳入了编译。建议include精确到源码目录,其他文件用exclude兜底。
5.4 增量编译缓存导致的诡异问题
incremental用久了偶尔会遇到“明明改了代码但编译结果没变”或者“删了文件还报旧错误”。这通常是.tsbuildinfo缓存和实际文件状态不一致。最直接的解法是删掉.tsbuildinfo重新全量编译。为了减少这类问题,把缓存文件放进outDir,并在清理脚本里连同dist一起删。
另外,tsc -b在 monorepo 里如果某个包的tsbuildinfo损坏,可能导致整个构建图卡住。遇到构建行为异常时,先试tsc -b --force强制全量重建,能排除大部分缓存问题。
5.5 常见问题速查表
| 症状 | 可能原因 | 排查动作 |
|---|---|---|
| 编辑器与命令行结果不一致 | TS 版本不同 / 配置不同 | 固定工作区 TS 版本,tsc --showConfig |
| 别名运行时找不到 | 运行时未配 alias | 检查打包配置,或用tsc-alias |
| 输出多一层目录 | rootDir未设或设错 | 显式设rootDir为源码目录 |
| 编译结果不更新 | 增量缓存不一致 | 删除.tsbuildinfo,tsc -b --force |
| 第三方库类型报错 | 库声明文件质量问题 | 开skipLibCheck |
| ESM 导入报扩展名错误 | moduleResolution为 NodeNext | 相对导入补.js扩展名 |
baseUrl弃用警告 | 用了旧配置 | 移除baseUrl,paths独立使用 |
6. 我踩过的坑和几条实用建议
先说一个我印象最深的坑。有次接手一个项目,tsconfig.json里include写的是["**/*"],exclude只排了node_modules。结果dist目录里的旧编译产物被当成源码又编译了一遍,输出嵌套了好几层,构建越来越慢。排查了半天才反应过来是include太宽。从那以后我养成了习惯:include永远精确到源码目录,exclude永远把dist、coverage、build这些产物目录列全。
第二个坑是关于strict的。有个老项目一直没开strictNullChecks,某次升级依赖后类型定义变了,一堆地方开始报错。当时想一次性开strict全修,结果几百个错误根本改不完。后来改成按目录逐步开,先在新代码目录开严格模式,老代码用单独的配置放宽,慢慢迁移。这个过程教会我:类型严格度是可以渐进提升的,别指望一步到位。
第三个是关于extends的浅合并。我在一个 monorepo 里让子包继承根配置,根配置里配了paths,子包想加一个自己的别名,就只写了自己的那个。结果根配置的别名全丢了,编辑器一片红。查了文档才确认extends是整体覆盖而非深度合并。解决办法是要么在子配置里把父配置的paths完整重写一遍,要么把公共别名抽到一个单独的基础配置里,两边都继承它。
几条实用建议收尾。第一,把tsc --noEmit加进 CI 的必过步骤,类型检查不该只靠编辑器。第二,定期跑tsc --showConfig看看实际生效的配置,尤其是用了extends之后,确认合并结果符合预期。第三,关注 TypeScript 的弃用警告,baseUrl、moduleResolution: node10这些都在迁移窗口期,早改早省心。第四,配置里多写注释——tsconfig.json支持 JSONC,允许注释,把每个非默认选项的原因写清楚,半年后的你会感谢现在的自己。