1. 为什么每个 TypeScript 项目都需要认真对待 tsconfig.json
很多人写 TypeScript 写了半年,tsconfig.json还是从脚手架里复制过来的那一份,从来没打开看过。直到某天发现类型检查不生效、路径别名在编辑器里报红、打包产物里混进了测试文件,才开始回头翻这个文件。我见过太多项目因为tsconfig.json配置不当,导致线上出现本可以在编译期拦截的空值错误,也见过团队因为strict模式没开,白白浪费了几周的排查时间。
tsconfig.json是 TypeScript 编译器(tsc)的核心配置文件,它决定了三件事:哪些文件会被编译、用什么规则编译、编译产物长什么样。这三个问题听起来简单,但每一个背后都有一堆选项在互相牵制。比如你开了composite就得考虑declaration,用了paths就得同步配置打包工具的alias,改了moduleResolution可能整个项目的 import 路径都要跟着调整。
这篇文章适合三类人看:刚接触 TypeScript 想搞清楚配置含义的新手、正在搭建项目脚手架需要定制编译策略的开发者、以及被类型检查问题折腾过想系统梳理一遍的老手。我会从整体设计思路讲到具体选项的实操细节,把每个关键配置背后的“为什么”讲清楚,而不是只列一张选项表让你自己猜。
2. tsconfig.json 的整体设计与核心思路拆解
2.1 配置文件的三种存在形态与优先级
tsconfig.json并不是只有一种写法。实际项目中你会遇到三种形态,理解它们的区别能帮你少踩很多坑。
第一种是单文件配置,所有选项写在一个tsconfig.json里。这种适合小型项目或者学习阶段,简单直接。第二种是继承式配置,通过extends字段引用一个基础配置,再覆盖自己需要的部分。团队协作中这种方式最实用,把公共规则抽到tsconfig.base.json,各子项目按需覆盖。第三种是项目引用(Project References),通过references字段把多个子项目的配置串联起来,适合 monorepo 场景。
优先级方面,extends的覆盖规则是浅合并:子配置里写了某个字段就完全覆盖父配置的同名字段,没写的就继承。注意compilerOptions里的对象类型选项(比如paths)是整体替换而不是深度合并,这一点很多人会搞错。我踩过的坑是:父配置里定义了paths映射@/*,子配置想再加一个@utils/*,结果直接写了一个新的paths,把父配置的映射全冲掉了。正确做法是在子配置里把父配置的映射也写一遍,或者用工具做深度合并。
提示:
extends的值可以是相对路径,也可以是 npm 包名。社区里比较常用的基础配置包有@tsconfig/node18、@tsconfig/strictest等,直接继承能省不少事。
2.2 编译范围与产物控制的设计逻辑
tsconfig.json里控制“编译哪些文件”的字段有三组:files、include、exclude。它们的关系不是并列的,而是有明确的优先级。
files是最精确的控制方式,列出的文件一定会被编译,不管exclude怎么写。include用 glob 模式匹配一批文件,exclude则从include的结果里排除掉一部分。默认情况下,如果没写files和include,编译器会从当前目录开始递归查找所有.ts、.tsx、.d.ts文件,但会排除node_modules、bower_components、jspm_packages以及outDir指定的目录。
这里有个容易忽略的细节:exclude只对include生效,对files无效。也就是说如果你在files里显式列了一个文件,即使它在exclude的范围内,照样会被编译。另外exclude里的路径是相对于tsconfig.json所在目录解析的,不是相对于include的基准路径。
产物控制方面,outDir决定编译输出的目录,rootDir决定源码的根目录。这两个要配合使用。如果rootDir没设置,编译器会自动推断一个“所有输入文件的公共根目录”,这个推断结果有时候会出乎意料。比如你的源码在src/下,但根目录有个global.d.ts,编译器可能把根目录推断成项目根,导致输出结构变成dist/src/xxx.js而不是dist/xxx.js。显式设置rootDir: "./src"能避免这个问题。
2.3 严格模式与类型检查的取舍策略
strict是一个总开关,它一次性打开了一批严格检查选项,包括strictNullChecks、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitAny、noImplicitThis、alwaysStrict等。新项目我强烈建议直接开strict: true,因为关掉这些检查省下的时间,远远比不上后期排查空值错误花的时间。
但老项目迁移时,一次性开strict可能会冒出几百个错误,团队根本改不过来。这时候可以采取渐进策略:先开noImplicitAny把隐式 any 的问题解决掉,再开strictNullChecks处理空值,最后逐步打开其他选项。每个选项单独开启,配合// @ts-expect-error临时压制个别改不动的地方,比一刀切要现实得多。
skipLibCheck: true是另一个值得单独说的选项。它会跳过所有.d.ts文件的类型检查。很多人担心这样会漏掉类型错误,但实际上第三方库的声明文件质量参差不齐,开着skipLibCheck能避免因为某个依赖的类型声明问题导致整个项目编译失败。我实测下来,开启后编译速度能提升 20% 到 40%,尤其是依赖多的项目效果明显。
3. compilerOptions 核心选项逐个拆解与实操要点
3.1 target、module、moduleResolution 三者的配合关系
这三个选项是compilerOptions里最核心的组合,它们决定了代码编译成什么样子、模块怎么解析。
target控制编译输出的 JavaScript 版本。可选值从ES3到ESNext。选哪个取决于你的运行环境:如果只跑在现代浏览器和 Node.js 18+,直接上ES2022甚至ESNext;如果要兼容老浏览器,就得降到ES5或ES2015。降得越低,编译产物里的辅助代码越多,体积越大。我一般建议新项目用ES2020起步,这个版本支持可选链和空值合并,编译产物也比较干净。
module控制模块系统的输出格式。常见取值有CommonJS、ESNext、ES2015、NodeNext等。如果是 Node.js 项目,用CommonJS或NodeNext;如果是前端项目配合打包工具,用ESNext让打包工具去做后续处理。这里有个坑:module设成ESNext时,import语句不会在编译阶段被转换成require,如果你直接拿tsc的产物去 Node.js 跑,会报Cannot use import statement outside a module。
moduleResolution控制 TypeScript 怎么找到 import 语句对应的文件。老版本默认是node(也叫node10),新版本推荐用bundler或NodeNext。bundler模式适合配合 Vite、Webpack 这类打包工具使用,它允许省略文件扩展名,也支持package.json里的exports字段。NodeNext则严格遵循 Node.js 的模块解析规则,import 时必须带扩展名。
注意:
moduleResolution: "node"和moduleResolution: "node10"在新版 TypeScript 中已被标记为弃用,未来版本会移除。新项目直接上bundler或NodeNext,别再用老的了。
三者的推荐组合我整理成了一张表:
| 场景 | target | module | moduleResolution |
|---|---|---|---|
| Node.js 后端 | ES2022 | NodeNext | NodeNext |
| 前端 + Vite | ES2020 | ESNext | bundler |
| 前端 + Webpack | ES2018 | ESNext | bundler |
| 库开发(双格式) | ES2018 | ESNext | bundler |
| 老项目兼容 | ES5 | CommonJS | node |
3.2 路径别名 paths 与 baseUrl 的正确用法
路径别名是提升开发体验的利器。没有别名时,你可能会写出import { foo } from "../../../../utils/foo"这种路径,层级一深就数不清。配上paths之后,可以写成import { foo } from "@/utils/foo",清爽很多。
配置方式是在compilerOptions里加:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@utils/*": ["src/utils/*"] } } }baseUrl是路径解析的基准目录,paths里的映射相对于baseUrl解析。这里有个重要变化:新版 TypeScript 中baseUrl已被标记为弃用,未来会移除。替代方案是直接在paths里写相对路径:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }这样就不需要baseUrl了。但要注意,paths只影响 TypeScript 的类型检查和编译,不会改变运行时的模块解析。也就是说,你配了@/*之后,tsc能正确找到类型,但打包工具(Vite、Webpack)和 Node.js 运行时并不知道这个映射。你需要在对应的地方也配一份:Vite 里配resolve.alias,Webpack 里配resolve.alias,Node.js 项目用tsconfig-paths这类工具在运行时做映射。
我踩过的坑是:只配了tsconfig.json的paths,本地开发时编辑器不报错,一跑测试就找不到模块。排查了半天才发现 Jest 也需要单独配moduleNameMapper。所以记住一句话:paths配一次,所有消费方都要跟着配。
3.3 类型声明与声明文件相关选项
如果你在开发一个 npm 库,declaration系列选项就很重要了。
declaration: true会让编译器为每个.ts文件生成对应的.d.ts声明文件。declarationDir可以指定声明文件的输出目录,不设的话跟outDir在一起。declarationMap: true会生成.d.ts.map文件,让编辑器能跳转到源码位置,调试库的时候很有用。
emitDeclarationOnly: true是个特殊选项,它只生成声明文件不生成 JS。这个在“用其他工具编译 JS、只用 tsc 生成类型”的场景下很有价值。比如你用 esbuild 做编译,用 tsc 做类型检查并输出.d.ts,就可以开这个选项。
还有一个容易混淆的选项是types。它控制哪些@types/*包会被自动包含进全局类型。默认情况下,node_modules/@types下的所有包都会被自动加载。如果你只想加载特定的几个,可以显式指定:
{ "compilerOptions": { "types": ["node", "jest"] } }这样其他@types包就不会被自动引入,能减少全局类型污染,也能加快编译速度。但要注意,设了types之后,没列出来的包就需要手动import才能用。
3.4 增量编译与性能优化选项
大型项目里tsc的全量编译可能要几十秒甚至几分钟,这时候增量编译就派上用场了。
incremental: true会生成一个.tsbuildinfo文件,记录上次编译的状态。下次编译时只重新编译改动的部分。这个文件默认跟outDir在一起,也可以用tsBuildInfoFile指定位置。实测在中等规模项目里,增量编译能把二次编译时间从 15 秒降到 3 秒左右。
composite: true是项目引用场景下的选项,它会自动开启declaration和incremental,并要求所有源码文件都在include范围内。用references串联多个子项目时,每个子项目都要开composite。
skipLibCheck: true前面提过了,跳过.d.ts检查,对编译速度提升明显。noEmit: true则完全不输出文件,只做类型检查,适合在 CI 里跑类型检查用。配合tsc --noEmit命令,能在不产生任何产物的前提下验证类型正确性。
4. 不同项目场景下的完整配置实操
4.1 Node.js 后端项目的配置方案
Node.js 后端项目的特点是运行环境明确、不需要考虑浏览器兼容、模块系统以 CommonJS 或 ESM 为主。下面是一份我常用的配置:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "sourceMap": true, "incremental": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] }几个关键点说明一下。esModuleInterop: true让你能用import express from "express"这种写法引入 CommonJS 模块,不开的话得写import * as express from "express"。resolveJsonModule: true允许直接 import JSON 文件,读配置文件时很方便。noUnusedLocals和noUnusedParameters会检查未使用的变量和参数,配合 ESLint 使用能保持代码整洁,但如果项目里有大量临时变量可能会觉得烦,可以按需关闭。
forceConsistentCasingInFileNames: true这个选项在 macOS 上特别重要。macOS 的文件系统默认大小写不敏感,import "./Foo"和import "./foo"在本地都能跑,但到了 Linux 服务器上就会报错。开启这个选项能在编译期就发现大小写不一致的问题。
4.2 前端 React 项目的配置方案
前端项目配合 Vite 或 Webpack 使用时,配置思路不太一样。因为打包工具会处理模块转换,tsc主要负责类型检查。
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "lib": ["ES2020", "DOM", "DOM.Iterable"], "strict": true, "noEmit": true, "esModuleInterop": true, "skipLibCheck": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "isolatedModules": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src"], "exclude": ["node_modules", "dist"] }jsx: "react-jsx"是 React 17 之后的新 JSX 转换方式,不需要在每个文件里import React。lib里加上DOM和DOM.Iterable才能用浏览器 API 的类型。isolatedModules: true确保每个文件能独立编译,这对 Vite 这类用 esbuild 做单文件转换的工具很重要,能提前发现那些“跨文件才能确定类型”的写法。
noEmit: true是因为前端项目不需要tsc输出 JS,打包工具会做这件事。tsc只负责在开发时和 CI 里做类型检查。Vite 项目里通常会在package.json的 scripts 里加一条"typecheck": "tsc --noEmit",提交前跑一遍。
4.3 Monorepo 项目引用的配置实践
Monorepo 场景下,项目引用(Project References)是管理多个子项目类型依赖的官方方案。假设你有packages/utils和packages/app两个子项目,app依赖utils。
根目录的tsconfig.json:
{ "files": [], "references": [ { "path": "./packages/utils" }, { "path": "./packages/app" } ] }packages/utils/tsconfig.json:
{ "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "outDir": "./dist", "rootDir": "./src", "strict": true }, "include": ["src/**/*"] }packages/app/tsconfig.json:
{ "compilerOptions": { "composite": true, "outDir": "./dist", "rootDir": "./src", "strict": true, "paths": { "@myorg/utils": ["../utils/src"] } }, "include": ["src/**/*"], "references": [ { "path": "../utils" } ] }关键点是composite: true必须开,它要求declaration也开启。references让app能直接引用utils的源码,tsc会按依赖顺序编译。构建时用tsc --build命令,它会自动处理依赖顺序和增量编译。
这里有个实操心得:paths指向../utils/src而不是../utils/dist,这样编辑器能直接跳转到源码,开发体验更好。但发布时要注意,app的产物里不能包含utils的源码,得确保utils先构建出dist,app引用的是构建后的产物。这个切换可以通过环境变量或不同的 tsconfig 文件来实现。
5. 常见问题与排查技巧实录
5.1 编译报错类问题速查
实际开发中遇到的tsconfig.json相关问题,大部分集中在几个典型场景。我整理了一张速查表:
| 报错信息 | 常见原因 | 解决方法 |
|---|---|---|
| Cannot find module '@/xxx' | paths 配了但打包工具没配 | 同步配置 Vite/Webpack 的 alias |
| File is not under 'rootDir' | rootDir 设置过窄 | 调整 rootDir 或把文件移入范围 |
| Cannot use import statement outside a module | module 设成了 ESNext 但直接跑 Node | 改用 NodeNext 或加打包步骤 |
| Duplicate identifier | types 里重复加载了全局类型 | 显式指定 types 列表 |
| Property does not exist on type | 类型声明缺失或版本不匹配 | 检查 @types 包版本,必要时自己补声明 |
| TS2307: Cannot find module | moduleResolution 不匹配 | 根据运行环境调整解析策略 |
其中“Cannot find module”是最常见的。排查思路是:先确认文件确实存在,再确认include范围覆盖到了,然后检查moduleResolution是否匹配你的模块系统,最后看paths映射是否正确。如果编辑器不报错但命令行报错,多半是编辑器用了不同的 TypeScript 版本,在 VSCode 里按Ctrl+Shift+P选“TypeScript: Select TypeScript Version”切换成项目本地版本。
5.2 编辑器与命令行行为不一致的排查
“编辑器里好好的,一跑tsc就报错”这种情况我遇到过好几次。原因通常有三个。
第一个是 TypeScript 版本不一致。VSCode 自带一个 TypeScript 版本,项目node_modules里可能装的是另一个版本。两个版本对某些选项的默认值处理不一样。解决办法是在 VSCode 设置里搜typescript.tsdk,指向项目的node_modules/typescript/lib。
第二个是tsconfig.json没被正确识别。VSCode 会从当前文件所在目录向上查找最近的tsconfig.json。如果你的文件在src/下,而tsconfig.json在项目根,正常情况下能找到。但如果中间某个目录有另一个tsconfig.json,就会用那个。排查方法是在 VSCode 里打开一个.ts文件,看状态栏显示的 TypeScript 版本和配置来源。
第三个是缓存问题。TypeScript 服务有时会缓存旧的类型信息,改了tsconfig.json后没生效。在 VSCode 里按Ctrl+Shift+P执行“TypeScript: Restart TS Server”能强制刷新。
5.3 从 JavaScript 项目迁移的渐进策略
老 JS 项目迁移到 TS,最怕的是一上来开strict冒出几百个错误。我的建议是分四步走。
第一步,先加tsconfig.json但设allowJs: true和checkJs: false,让.js文件也能被包含进来但不做类型检查。这一步只是让项目结构支持 TS,不改变任何行为。
第二步,把strict关掉,只开noImplicitAny: false,让现有代码能编译通过。然后逐个文件把.js改成.ts,改一个解决一个的类型错误。
第三步,等大部分文件都转成.ts后,开启noImplicitAny: true,把隐式 any 的地方补上类型。这一步工作量最大,但收益也最明显。
第四步,最后开strictNullChecks和完整的strict。这时候项目已经基本类型化了,剩下的空值问题不会太多。
整个过程可能要持续几周甚至几个月,取决于项目规模。关键是不要追求一步到位,每开一个选项就确保 CI 能过,避免积累大量技术债。
提示:迁移期间可以用
// @ts-nocheck在个别文件顶部临时关闭检查,但一定要加 TODO 注释并记录,否则很容易被遗忘。
6. 几个容易被忽略但很关键的配置细节
6.1 lib 与 target 的关系
lib选项决定编译器能识别哪些内置 API 的类型。比如你想用Promise,lib里就得有ES2015或更高;想用document,就得有DOM。target决定语法降级到什么程度,lib决定类型层面能识别哪些 API,两者是独立的。
常见误区是设了target: "ES5"就以为不能用Promise。实际上只要lib里有ES2015,类型检查就能过,Promise的 polyfill 由运行环境或打包工具负责。反过来,设了target: "ES2022"但lib只有ES5,用Array.prototype.includes就会报类型错误。
我的建议是lib至少包含target对应的 ES 版本,再加上运行环境需要的部分。Node.js 项目加["ES2022"],前端项目加["ES2020", "DOM", "DOM.Iterable"]。
6.2 noEmit 与 emitDeclarationOnly 的适用场景
noEmit: true表示不输出任何文件,只做类型检查。适合前端项目配合打包工具使用,或者 CI 里单独跑类型检查。
emitDeclarationOnly: true表示只输出.d.ts文件,不输出 JS。适合用 esbuild、swc 这类快速编译器做 JS 转换、用 tsc 专门生成类型的场景。这两个选项不能同时开,因为noEmit会覆盖emitDeclarationOnly。
还有一个noEmitOnError: true,表示有类型错误时不输出文件。默认是false,也就是即使报错也会输出 JS。这个默认值在开发时方便,但在 CI 构建时最好设成true,避免带着类型错误的产物被发布出去。
6.3 配置文件的组织与维护建议
项目大了之后,单个tsconfig.json会变得很长。我的做法是拆成三层:tsconfig.base.json放所有项目共用的严格规则和通用选项,tsconfig.json放当前项目的具体配置,tsconfig.build.json放构建时的特殊配置(比如排除测试文件、开启noEmitOnError)。
package.json的 scripts 里可以这样组织:
{ "scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json", "dev": "tsc -p tsconfig.json --watch" } }这样开发时用宽松一点的配置快速反馈,构建时用严格配置确保产物质量,CI 里单独跑typecheck做全量检查。三层配置各司其职,维护起来清晰很多。
我在实际项目里还养成了一个习惯:每次升级 TypeScript 版本后,跑一遍tsc --showConfig看看最终生效的配置是什么。这个命令会输出合并后的完整配置,能帮你发现extends链里有没有意外的覆盖。尤其是升级大版本时,某些选项的默认值会变,--showConfig能让你第一时间发现差异。