Skip to content

TypeScript (tsconfig) 工程化配置指南 ​

本文档系统梳理 TypeScript 在单环境(纯 Node 后端)与多环境(前端 + Node 脚本 / 全栈)项目中的标准 tsconfig 架构范式、extends 各字段的合并与覆盖策略、交叉共享代码的处理方案,以及循环引用的成因与规避方法。

一、核心痛点:为什么不能只靠一个默认配置? ​

在实际工程中,tsconfig.json 同时服务于两类消费者:

  1. 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" 等类型报错。
  2. 生产打包构建(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"] 并开启编译输出。
json
// 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"]
}
json
// 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 单向引用它:

json
// 1. tsconfig.shared.json(纯 ES 标准,不含 DOM 也不含 Node)
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "lib": ["ES2023"],
    "types": []
  },
  "include": ["src/shared/**/*.ts"]
}
json
// 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" }]
}
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 根配置:

text
tsconfig.json ──(references)──► tsconfig.app.json
      ▲                                │
      └───────────(extends)────────────┘

虽然前面提到 extends 不会继承 references,因此 TypeScript 语法上勉强允许这种写法,但它存在两个严重隐患:

  1. 配置属性污染:如果在根 tsconfig.json 里不小心写了 "files": [],子配置 extends 根配置后又忘了写自己的 include 或 files,子配置就会继承 "files": [] 导致什么都不编译。
  2. 工具链解析异常:部分第三方工具(旧版 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,编译器会直接报错:

text
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.jsontsc -b
维护成本极低(无需开启 composite,无 .tsbuildinfo 缓存负担)中等(严格隔离环境,适合多端混合工程)

本项目决策记录

本项目(enterprise-express)当前采用【范式一】:因全仓代码(src/、prisma/、tests/)均运行于 Node.js 24 环境,使用 tsconfig.json(全仓 IDE 与类型检查)+ tsconfig.build.json(仅编译 src/ 至 dist/)即可用最少的文件达到最优工程体验。