TypeScript (tsconfig) 工程化配置指南
本文档系统梳理 TypeScript 在单环境(纯 Node 后端)与多环境(前端 + Node 脚本 / 全栈)项目中的标准 tsconfig 架构范式、extends 各字段的合并与覆盖策略、交叉共享代码的处理方案,以及循环引用的成因与规避方法。
一、核心痛点:为什么不能只靠一个默认配置?
在实际工程中,tsconfig.json 同时服务于两类消费者:
- IDE 语言服务与静态检查(VS Code、ESLint、Vitest、
tsc --noEmit):- 默认只认项目根目录下的
tsconfig.json。 - 一旦某个
.ts文件(如prisma/seed.ts、tests/*.ts、vite.config.ts)不在根tsconfig.json的管辖范围内,VS Code 就会将其降级为“隐式项目(Inferred Project)”,丢失"types": ["node"]与"moduleResolution",导致出现找不到名称 "node:url"等类型报错。
- 默认只认项目根目录下的
- 生产打包构建(
npm run build):- 只希望将
src/下的业务源码编译输出到dist/,绝不能把tests/、prisma/seed.ts等辅助脚本一起编译进dist/。
- 只希望将
围绕这一痛点,行业形成了两大标准配置范式。
二、两大行业标准范式
范式一:tsconfig.json(全量检查)+ tsconfig.build.json(生产构建)
- 适用场景:纯 Node.js 后端项目(如本项目
enterprise-express、NestJS、Prisma 服务)。所有.ts文件运行环境一致(均为 Node.js),无浏览器DOM与Node全局类型冲突。 - 核心思路:
tsconfig.json(主配置):交给 VS Code、ESLint、Vitest 使用,include覆盖全仓库所有.ts文件,设置"noEmit": true。tsconfig.build.json(构建专用):交给npm run build使用,通过"extends": "./tsconfig.json"继承全部严格规则,仅将include收窄到["src/**/*.ts"]并开启编译输出。
// tsconfig.json(VS Code 默认读取,覆盖全仓)
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": ".",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "prisma/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
}// tsconfig.build.json(仅 npm run build 使用)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
},
"include": ["src/**/*.ts"]
}范式二:Solution-style references(工程引用 / 多环境隔离)
- 适用场景:前端工程(Vite / Vue / React)或全栈项目。项目中同时存在运行于浏览器的代码(需要
DOM,禁止node:)和运行于 Node.js 的配置/构建脚本(需要node:,禁止DOM)。 - 核心思路:
- 根
tsconfig.json:作为“空壳总机”,"files": []不直接纳管任何文件,通过"references"将子配置挂载给 VS Code。 - 子配置(
tsconfig.app.json/tsconfig.node.json):各自通过include按路径认领文件,拥有独立的lib和types环境。
- 根
三、extends 继承与合并策略全解析
当子配置使用 "extends": "./tsconfig.base.json" 时,TypeScript 并不是对所有字段都做深度合并(Deep Merge)。不同顶层字段和二级字段有着严格的合并/覆盖规则:
1. 顶层字段合并策略表
| 顶层字段 | 合并策略 | 详细说明与踩坑预警 |
|---|---|---|
compilerOptions | 浅合并(一层的键值合并) | 子配置与父配置的 compilerOptions 按属性名合并;同名属性由子配置覆盖父配置,未声明的属性保留父配置的值。 |
include | 完全覆盖(不合并!) | 只要子配置写了 include,父配置的 include 整组作废,不会拼接数组。 |
exclude | 完全覆盖(不合并!) | 子配置若声明 exclude,会彻底替换父配置的 exclude。 |
files | 完全覆盖(不合并!) | 子配置若声明 files,会彻底替换父配置的 files。 |
references | 不继承(忽略!) | 核心规则:references 属于工程拓扑关系,永远不会被 extends 继承。 |
watchOptions | 浅合并 | 同 compilerOptions,按属性名合并与覆盖。 |
2. compilerOptions 内部特殊字段的覆盖行为
因为 compilerOptions 只做一层浅合并,所以其内部的数组型和对象型配置在继承时会被整体替换,而不会自动追加:
compilerOptions 字段 | 值类型 | 子配置重写时的行为 | 示例与注意事项 |
|---|---|---|---|
strict / target 等 | 布尔 / 字符串 | 直接覆盖 | 父 "noEmit": true,子 "noEmit": false $\rightarrow$ 最终为 false。 |
lib | 数组 string[] | 整组替换 | 父 ["ES2023"],子写 ["DOM"] $\rightarrow$ 最终只有 ["DOM"],丢失 ES2023!子配置必须写全 ["ES2023", "DOM"]。 |
types | 数组 string[] | 整组替换 | 父 ["node"],子写 ["vitest/globals"] $\rightarrow$ 最终丢失 node!必须写 ["node", "vitest/globals"]。 |
paths | 对象 Record | 整对象替换 | 父定义了 @/*,子定义了 #/* $\rightarrow$ 子配置会丢失 @/*!子配置若重写 paths,必须把父级的别名一并抄过来。 |
plugins | 数组 object[] | 整组替换 | 子配置重写时会覆盖父配置的全部 TS 插件。 |
数组型与对象型字段的"整组替换"陷阱
子配置重写 lib / types / paths / plugins 时,被重写的属性不会与父配置合并,而是被整体替换。在跨环境场景下这意味着:每个子配置必须自己写完整的 lib 与 types,不要假设父配置会"自动补齐"。
最佳实践:路径别名(
paths)应当统一放在最底层的公共配置中,子配置尽量不要单独重写paths。
四、app 与 node 交叉地带(共享代码)如何配置?
当浏览器端代码(app)与 Node 脚本(node)需要共用同一批文件(例如 src/shared/constants.ts 或接口类型契约 src/shared/types.ts)时,有以下两种标准方案:
方案 A:轻量交叉 —— 双方 include 共同纳入(交集约束)
如果只有少量共享文件(如 src/shared/**/*),直接让 tsconfig.app.json 和 tsconfig.node.json 的 include 都包含该目录:
tsconfig.app.json:"include": ["src/**/*"]tsconfig.node.json:"include": ["vite.config.ts", "prisma/**/*.ts", "src/shared/**/*"]
代码约束:因为 src/shared/ 同时接受两套配置的检查,它内部只能写跨平台纯 TypeScript(环境交集)——既不能调用 window/document(否则 node 检查报错),也不能调用 fs/process(否则 app 检查报错)。
方案 B:重度交叉 —— 独立拆分 tsconfig.shared.json(推荐大型项目)
当共享模块规模较大时,将其抽离为独立的第 3 个子工程 tsconfig.shared.json,由 app 和 node 通过 references 单向引用它:
// 1. tsconfig.shared.json(纯 ES 标准,不含 DOM 也不含 Node)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2023"],
"types": []
},
"include": ["src/shared/**/*.ts"]
}// 2. tsconfig.app.json(排除 shared 目录,改用 references 引用)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2023", "DOM"],
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/shared/**/*.ts"],
"references": [{ "path": "./tsconfig.shared.json" }]
}// 3. tsconfig.node.json(同样通过 references 引用 shared)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["vite.config.ts", "prisma/**/*.ts"],
"references": [{ "path": "./tsconfig.shared.json" }]
}五、会不会出现循环引用问题?如何彻底规避?
在复杂的多 tsconfig 体系中,确实可能遇到两类循环引用,TypeScript 编译器对此有明确的报错机制:
风险 1:extends 与 references 在根配置上自指(伪循环 / 继承死锁)
错误写法示例: 很多人为了省掉 tsconfig.base.json,把公共 compilerOptions 写在根 tsconfig.json 里,同时又在根 tsconfig.json 里写 references 指向子配置,子配置再 extends 根配置:
tsconfig.json ──(references)──► tsconfig.app.json
▲ │
└───────────(extends)────────────┘虽然前面提到 extends 不会继承 references,因此 TypeScript 语法上勉强允许这种写法,但它存在两个严重隐患:
- 配置属性污染:如果在根
tsconfig.json里不小心写了"files": [],子配置extends根配置后又忘了写自己的include或files,子配置就会继承"files": []导致什么都不编译。 - 工具链解析异常:部分第三方工具(旧版
tsconfig-paths、部分打包器插件)在递归解析配置文件时,遇到A references B且B extends A会陷入无限递归。
彻底规避方法: 凡是采用范式二(Solution-style references),一律将公共配置独立为 tsconfig.base.json!
tsconfig.base.json:只放公共compilerOptions,不放files、include、references。tsconfig.json:只放"files": []和"references",不放任何compilerOptions。tsconfig.app.json/tsconfig.node.json:统一"extends": "./tsconfig.base.json"。
这样继承链(extends)与引用链(references)完全正交,绝无环路:
风险 2:子配置之间的 references 双向依赖(编译期真循环)
错误写法示例:
tsconfig.app.json的references指向了tsconfig.node.json;- 同时
tsconfig.node.json的references又指向了tsconfig.app.json。
后果: TypeScript 的 Project References 要求工程依赖图必须是有向无环图(DAG),因为它需要按拓扑顺序先后编译生成 .d.ts。一旦出现双向 references,编译器会直接报错:
error TS6202: Project references may not form a circular graph.双向 references 会被 TypeScript 直接拒绝
工程引用必须是 DAG。一旦出现 app ↔ node 双向 references,tsc -b 会报 TS6202。这条规则由编译器强制执行,没有"逃生口"。
彻底规避方法: 遵守单向依赖原则(下沉公共层):
app和node是平级的叶子节点,严禁互相references;- 一旦发现
app需要引用node里的类型,或者node需要引用app里的常量,立刻将该文件下沉到src/shared/(对应tsconfig.shared.json),让app和node同时单向指向shared。
六、最终选型决策速查表
| 评估维度 | 选【范式一】tsconfig.json + tsconfig.build.json | 选【范式二】tsconfig.base.json + references |
|---|---|---|
| 项目类型 | 纯 Node.js 后端服务、CLI 工具、Node 类库 | 前端项目(Vite/React/Vue)、前后端同仓全栈应用 |
| 全局环境 | 单一环境(全部文件均运行在 Node.js) | 多套互斥环境(浏览器 DOM vs 服务端 Node) |
| 配置文件数 | 2 个(tsconfig.json、tsconfig.build.json) | 4~5 个(tsconfig.json、base、app、node、shared) |
| 构建命令 | tsc -p tsconfig.build.json | tsc -b |
| 维护成本 | 极低(无需开启 composite,无 .tsbuildinfo 缓存负担) | 中等(严格隔离环境,适合多端混合工程) |
本项目决策记录
本项目(enterprise-express)当前采用【范式一】:因全仓代码(src/、prisma/、tests/)均运行于 Node.js 24 环境,使用 tsconfig.json(全仓 IDE 与类型检查)+ tsconfig.build.json(仅编译 src/ 至 dist/)即可用最少的文件达到最优工程体验。
