先说结论:Turborepo/Nx这类 monorepo 工具本身不负责类型检查,它们只负责把命令编排成带缓存的任务图。跨包引用失败的根子,几乎都在tsconfig的composite、references、paths三者没有对齐上。我见过太多项目,pnpm workspace 里import一个内部包,IDE 不报红、单测也能跑,一上 CI 执行tsc --build全线崩盘。这篇文章就把我从报错到修复的完整过程拆开讲,搞清楚 composite 到底在约束什么,以及 Turborepo/Nx 场景下怎么把类型检查产物正确纳入任务依赖。
1. 胶带里的真相:IDE 放行、单测全过,偏偏 tsc 崩在跨包引用
很多 monorepo 新手都经历过一个诡异阶段:编辑器里import { Button } from '@repo/ui'完全没有红线,vitest跑测试也能过,甚至vite dev都正常。但当你执行一次全量构建,tsc却抛出一堆莫名其妙的错误,指向的恰恰是刚才还好好的跨包引用。
这种“局部正常”和“全局崩溃”的撕裂感,原因在于不同工具解析模块的路径完全不同。IDE 和打包器通常会跟随package.json的exports字段或者tsconfig里的paths映射,直接跳转到源码.ts文件;而tsc在做项目构建时,走的是另一套逻辑——它要先确认每个包是否是一个完整的编译单元,跨包引用是否落在被引用包的声明产物之上。两边规则不一致,就会出现“靠 IDE 习惯思考,却被编译器教育”的结果。
1.1 症状一:IDE 没报错,CI 里 TS6059 砸场
TS6059 的完整报错大意是:文件不在rootDir下,rootDir预期包含所有源文件。典型场景是这样的:你的根 tsconfig 把rootDir设成了仓库根目录.,然后apps/web里通过paths把@repo/ui直接映射到了packages/ui/src/index.ts。TypeScript 在类型检查时把被引用包的源文件拉进了当前项目,这些文件不在apps/web自己的rootDir之内,于是编译器认为“你在污染输出目录结构”。
这个问题在 IDE 中几乎无法暴露,因为 IDE 的 Language Service 默认按最宽松的解析策略提供补全和跳转,只有真正执行tsc时才会校验 rootDir。你越是依赖paths做跨包跳转,构建时就越容易被 TS6059 追着打。
1.2 症状二:TS6306,直接点名 composite
TS6306 是我见过的最直白的错误:引用项目必须设置 "composite": true。这个错误通常发生在你尝试在tsconfig中用references引用另一个包,但那个包的 tsconfig 里没有打开composite。TypeScript 对项目引用的要求非常死板:被引用的项目必须是一个“可构建的复合项目”,否则它不知道该怎么增量生成声明文件、不知道从哪里读取.tsbuildinfo。
很多从单仓库转 monorepo 的团队会惯性式地写paths代替references,因为这看起来最简单。但 TypeScript 的项目引用机制有明确前置条件:被引用包必须composite: true。这是第一个需要被正视的硬约束,不是“建议”,是“必须”。
1.3 为什么 Turborepo/Nx 不背锅,但会放大问题
Turborepo 和 Nx 本质上是任务执行器。Turborepo 根据turbo.json里的任务定义与dependsOn关系调度命令,Nx 则通过项目图和 target 推断来执行。它们都不知道 TypeScript 内部的项目引用关系,只会忠实地执行你在package.jsonscripts 里写的命令。
可它们会放大问题:因为缓存机制。假设你之前有一次构建成功,Turborepo 把dist目录缓存了下来;之后你改了被引用包的源码,但某个任务的输入没有正确包含该包,Turbo 可能直接命中旧缓存,给你返回一个过期的dist。更隐蔽的是,如果 TS6059 之类的问题在缓存结果中被“固化”了,缓存恢复后你看到的是同样的错误,但你已经不知道这份错误到底是当前代码产生的,还是两天前某个临时改动留下的。
所以,当我们在 monorepo 工具链下谈“跨包引用失败”时,不能只看tsc的报错信息,还要检查任务依赖图是否正确、缓存输入是否完整。前半部分是 TypeScript 的语法规则问题,后半部分是构建系统的正确性设计问题,两者都需要解决。
2. composite 不是为“单包舒适区”设计的:它带来的硬约束与背后考量
composite这个配置项在 TypeScript 官方文档里的解释很简略,大意是“启用项目编译的约束,使 TypeScript 可以确定项目是否已构建”。但在 monorepo 情境下,它的实际含义要重得多。我把 composite 理解为包与包之间“类型边界契约”的强制条款:一个包要想被别人安全引用,必须能独立产出.d.ts声明,必须知道自己的源码边界在哪里,必须能被增量跟踪。
2.1 composite 打开后立刻变严格的三件事
当你把某个包的 tsconfig 设置"composite": true时,TypeScript 会强制你遵守至少三条规则:
- 必须生成声明文件,所以
declaration隐含为 true,默认还会打开declarationMap,用于源码与声明文件之间互相跳转。 - 必须设置
rootDir,这个值决定了该编译单元的源码根。跨包引用如果拉进外部源码,立刻污染 rootDir 判定。 - 不允许
noEmit。以前很多纯前端项目习惯用tsc --noEmit做类型检查,但在 composite 项目里不可以,因为编译器必须产出声明文件和.tsbuildinfo。
这些约束乍看很烦人,尤其第三条让很多 Vite 用户不适应。但换个角度想:TypeScript 需要依赖这些产物来完成“项目引用”的增量逻辑。没有声明文件,下游项目无法建立独立类型边界;没有构建信息文件,下次构建无法判断哪些文件变更过。
2.2 声明产物和隔离:为什么跨包类型边界必须走 .d.ts
假设没有 composite,@repo/web引用@repo/ui时,TypeScript 可能会顺着源码路径直接检查packages/ui/src里的所有内容。这在一两个包的小项目里没问题,但当包数量增长到十个、二十个,每次全量类型检查都会把整个依赖子树扫描一遍,构建时间呈指数级恶化。
开启 composite 后,@repo/ui会先把自己编译成dist下的index.js和index.d.ts,同时生成.tsbuildinfo。@repo/web在类型检查时只读取@repo/ui的dist/index.d.ts,不再进入源码目录。这才是真正的“边界”:每个包只对自己的声明负责,下游看到的是稳定契约,而不是随时变化的源码细节。
这也是为什么很多项目把启用 composite 的 tsconfig 命名为tsconfig.build.json或直接作为主配置。它天然适合与 Turborepo/Nx 的增量任务配合:每个包的构建是独立任务,产出的声明文件可以被缓存。
2.3 不适配的常见根 tsconfig 写法
我很常见到仓库根目录有一个“万能” tsconfig,里面写着"compilerOptions": { "noEmit": true },所有子包都extends它。这种配置在单包项目里很安全,一进入 monorepo + project references 就会像撞墙一样报错:子包打开 composite,父配置却要求 noEmit,两者直接冲突。
类似的问题还有:根配置里写了"rootDir": ".",子包含盖成"rootDir": "src"后又因为某些历史文件目录不一致而报错。正确思路是:根 tsconfig 只承担“公共编译选项”的角色,不写 noEmit、不写 rootDir、不写 composite,把这些约束下沉到每个实际编译的包配置里。基线配置越“抽象”,子包越自由;反之,任何全局 writable 的约束都会成为定时炸弹。
3. 从报错到绿灯:references、rootDir 与构建模式的一整套可复现配置
理论聊完,直接看一套能跑通的配置模板。下面这套结构是 pnpm workspace 加 Turborepo,包管理器换成 yarn/npm 也同理。仓库结构如下:
repo-root ├── packages │ ├── ui │ │ ├── src │ │ │ └── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── utils │ ├── src │ │ └── index.ts │ ├── package.json │ └── tsconfig.json ├── apps │ └── web │ ├── src │ │ └── main.ts │ ├── package.json │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json └── turbo.json3.1 根 tsconfig.base.json:只放公共编译选项
// tsconfig.base.json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true } }这里刻意不写composite、rootDir、outDir、noEmit。这些是属于单个编译单元的决策,放到子配置里。moduleResolution选Bundler是因为仓库里的应用大多走 Vite/打包器运行时代码,选择它解析exports字段更顺。如果你的产物需要在 Node 原生 ESM 下直接执行,可以换成NodeNext,但代价是相对导入必须带.js扩展名,这个差异要提前想清楚。
3.2 各包 tsconfig:composite、rootDir、tsBuildInfoFile 齐活
packages/ui/tsconfig.json:
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "../../node_modules/.cache/ui.tsbuildinfo", "declaration": true, "declarationMap": true }, "include": ["src"], "references": [] }tsBuildInfoFile把增量构建信息统一放到仓库根目录node_modules/.cache下,避免每个包自己生成一堆.tsbuildinfo垃圾文件,也方便在 CI 里统一清理缓存。declaration其实在 composite 下是隐式开启的,但显式写出可以让新同事一眼看懂这个包的产物预期。
apps/web/tsconfig.json稍微不同,因为它要引用其他包:
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "../../node_modules/.cache/web.tsbuildinfo", "declaration": true, "declarationMap": true, "emitDeclarationOnly": true }, "include": ["src"], "references": [ { "path": "../../packages/ui" }, { "path": "../../packages/utils" } ] }给apps/web打开emitDeclarationOnly是个人推荐:应用本身的 JS 产物交给 Vite 处理,不需要 tsc 生成一份多余的 JS,但 composite 又要求必须产出声明文件,那就只产出.d.ts,不产出.js。这一招既满足 composite 的硬性要求,又不打断应用的构建流程。
3.3 根 tsconfig:solution 风格的关键一步
仓库根目录的tsconfig.json用 solution 模式:
{ "files": [], "references": [ { "path": "./packages/ui" }, { "path": "./packages/utils" }, { "path": "./apps/web" } ] }这样你可以在根目录直接执行tsc -b,TypeScript 会按引用关系自底向上构建所有包。files为空数组意味着这个配置本身不编译任何文件,它只是项目图的入口。这是tsc --build的标准用法,也是整个方案里最容易被忽略的一环——很多人把所有包配好了 references,却在命令行里对每个包分别执行tsc -p,结果项目引用完全不生效。
3.4 构建命令的变革:只用 tsc -b,不要 tsconfig 单包硬编
跨包引用在 build mode 下才生效,这是 TypeScript 项目引用最核心的行为差异。单独执行tsc -p packages/ui不会构建它引用的包,也不会主动刷新被引用项目的产物。正确姿势是:
tsc -b # 构建根 solution 引用的所有项目 tsc -b --clean # 删除所有构建过的产物与 tsbuildinfo tsc -b --force # 忽略 tsbuildinfo,强制整体重建在 Turborepo 场景下,每个包的package.jsonscripts 可以写成"build": "tsc -b",由 Turbo 统一调度。但要注意一个细节:如果某个包的 build 命令是tsc -b ../../tsconfig.json这种跨目录写法,不同包管理器的 cwd 解析可能不一致,尽量让每个包执行tsc -b时自动使用当前目录下的 tsconfig,也就是不传参数。
到这里,跨包引用失败的大部分基础问题已经能被解决。但把这套配置丢进 Turborepo/Nx 之后,真正的坑才开始冒头:缓存。
4. Turborepo/Nx 任务编排里的缓存陷阱:类型检查产物同样需要纳入依赖图
很多团队把tsc配置好了,却依然在 CI 上见到“间歇性”跨包报错。这种诡异现象十有八九是任务编排和缓存导致的。你要意识到,在一个 monorepo 工具眼里,@repo/web的 typecheck 任务和@repo/ui的 build 任务是两个相互独立的task。如果你没有显式声明dependsOn,Turbo/Nx 根本不知道 web 的类型检查必须等 ui 构建出声明文件以后才能跑。
4.1 Turbo 2.x 的 task 配置:dependsOn 和 outputs 要覆盖声明
Turborepo 2.x 中原来的pipeline关键字改成了tasks。一个能正确覆盖 TypeScript 项目引用的配置长这样:
// turbo.json { "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "typecheck": { "dependsOn": ["^build"] } } }dependsOn: ["^build"]表示“本任务的执行依赖所有上游依赖包的 build 任务先完成”。注意,typecheck 依赖上游的 build,而不是依赖上游的 typecheck。因为 web 做类型检查时读取的是 ui 产出的dist/index.d.ts,ui 必须先执行一次tsc -b把声明文件生成出来。如果你只写dependsOn: ["^typecheck"],上游只是跑了tsc --noEmit,什么都没产出,web 类型检查必然失败。
outputs里写的dist/**会被 Turborepo 缓存,这里建议确认你声明文件确实输出在dist下。如果某个包的outDir改成了lib,而outputs忘了同步修改,缓存命中时 Turborepo 会恢复旧路径下的产物,类型检查就会读到过期的.d.ts。
4.2 Nx 的 executor 与 cache inputs:把 tsconfig 变化纳入哈希
Nx 处理方式更“框架化”。它通过@nx/js:tscexecutor 直接执行 TypeScript 构建,并在nx.json的targetDefaults里设置缓存规则:
{ "targetDefaults": { "build": { "dependsOn": ["^build"], "outputs": ["{projectRoot}/dist"] } } }Nx 的默认缓存输入包括源码和tsconfig文件,但有一个细节要留意:如果你的tsconfig.base.json在仓库根目录,Nx 哈希项目时未必会自动把它算进来。稳妥做法是在nx.json里显式声明:
{ "namedInputs": { "default": ["{projectRoot}/**/*", "tsconfig.base.json", "!{projectRoot}/**/*.md"] } }否则你改了全局编译选项,Nx 可能因为项目文件哈希没变而命中旧缓存。这种“缓存命中但结果错误”的问题比没有缓存还难排查,因为日志里自变量清清楚楚地写着cached.
4.3 还原缓存之后你应该验证什么
无论用 Turborepo 还是 Nx,缓存命中后不要只看任务状态是绿色的,必须验证缓存内容正确。我的习惯是这样:
- 检查被引用包的
dist下是否真的存在.d.ts和.d.ts.map文件,而不仅仅有.js。如果.js存在但.d.ts缺失,说明那次构建并没有以 composite 模式完成,缓存的内容本身就是坏的。 - 查看
.tsbuildinfo的更新时间是否合理。声明文件讲道理应该跟着源码改动同步刷新。 - 在 CI 里故意执行一次
--force或者--skip-nx-cache,对比强制重建与缓存命中的产物差异。
这几个动作能帮你区分“代码问题”和“缓存问题”。我见过不少团队只加缓存不看缓存,最后花了一整天查源码,结果罪魁祸首是远程缓存里存了一份老旧的dist。记住一句话:缓存只忠实于它被创建时的输入,不忠实于你脑补的“当前代码”。
5. 我把常见报错整理成一张排障表,以及“先怀疑谁”的排查顺序
文章后半篇讲实操。下面这张表汇总了我处理 monorepo 跨包引用问题时遇到的高频报错,以及对应的根因与修法。建议直接存下来,下次报错先对着表定位,比在搜索引擎里碰运气快得多。
| 报错代码 | 报错含义 | 常见根因 | 修复方向 |
|---|---|---|---|
| TS6059 | 文件不在 rootDir 下 | rootDir 设置过宽,paths 把外部源码拉进当前编译单元 | 每个包收紧 rootDir 到自己的 src,不要用 paths 跨包指源码 |
| TS6306 | 引用项目必须设置 composite | references 指向的包未开启 composite | 给被引用包 tsconfig 增加"composite": true |
| TS6307 | 文件不在被引用项目的文件列表中 | 被引用包 include 范围不完整,或 import 了不在 src 内的文件 | 调整被引用包的 include,确保所需文件属于其编译范围 |
| TS6305 | 输出文件未构建 | .tsbuildinfo 与当前源码状态不一致,常见于缓存或手动删档后 | 执行tsc -b --force清理重建,或清掉 .tsbuildinfo |
| TS5055 | 输出文件会覆盖输入文件 | outDir 没有设置,或 outDir 与源码目录重叠 | 显式设置 outDir 与 rootDir,确保输出落到独立目录 |
| TS2307 | 找不到模块 | 包没构建出产物、moduleResolution 无法解析 exports,或引用路径写错 | 先构建上游包,再检查 package.json exports 与 tsconfig moduleResolution |
5.1 典型根因排序:先查包,再查引用,最后查命令
排障顺序很重要。我自己的习惯是这样:
第一,确认被引用包是否已经构建。进入packages/ui/dist看一眼有没有.js和.d.ts。没有的话,问题根本不在这包当前源码,而是你还没有执行它的 build 任务,或者在 Turborepo/Nx 中它的 build 没有被调度。
第二,确认 references 是否完整且互无环。用tsc -b --verbose观察 TypeScript 实际选择的构建顺序,如果某个包没有被纳入构建列表,说明根 solution 配置漏写了引用。如果出现循环引用,TypeScript 会直接报错,这时需要把共享类型抽到更底层的包。
第三,确认命令形态。是tsc -p还是tsc -b,结果可能完全不同。项目引用必须通过 build mode 驱动,单包编译不会自动构建依赖。很多“昨天还能过今天突然不行”的灵异问题,最后发现只是因为某个环节用了tsc -p。
第四,才轮到怀疑缓存。关掉缓存强制重建,如果错误消失,那就是缓存输入配置不完整;如果错误依旧,说明代码层面的问题还没解决。
5.2 两个容易再犯的隐藏坑:继承与公开导出漂移
第一个隐藏坑是 tsconfig 继承导致的隐式字段覆盖。子包extends根配置时,根配置里任何一个你没意识到的rootDir或outDir都会被继承。更隐蔽的是include数组,它在继承时不会合并,子包配置里的include会直接替换父级配置。这种替换经常导致“我这个包明明有 src 目录,为什么 TS 说找不到文件”的困惑。建议所有子包 tsconfig 都显式写清include,绝不依赖父级的默认扫描。
第二个隐藏坑是package.json的exports字段与声明文件不一致。composite 构建完成后,下游包通过 moduleResolution 解析到的是exports指向的产物路径。如果exports写的是"./dist/index.js",而实际声明文件叫./dist/index.d.ts,TypeScript 会自动找同名.d.ts,一般没问题;但如果exports里有"types"条件,并且指错了路径,下面就是一串莫名其妙的 TS2307。改完 tsconfig 后务必同步 check 一下包入口。
5.3 我个人的调试经验:让编译器说清楚它看到了什么
最后分享一个实用到几乎是“绝招”的技巧:利用tsc --traceResolution查看模块解析日志。当 TS2307 出现时,它只会告诉你找不到模块,却不告诉你它尝试了哪些路径。执行:
tsc -p packages/web/tsconfig.json --traceResolution 2>&1 | grep '@repo/ui' -A 20你会看到 TypeScript 查找模块时真实尝试过的所有路径。是走到了packages/ui/dist/index.d.ts还是走到了packages/ui/src/index.ts,是解析了 package.json 的 exports 还是直接用了 node_modules 里的符号链接,全都能看出来。这一步几乎能定位 90% 的跨包引用问题,因为大多数这类错误不是“找不到”,而是“找错了地方”。
另一个惯用操作是新建一个干净缓存目录做验证。每次 TS 主版本升级或者涉及moduleResolution变更时,我不会只在 CI 里跑增量构建,而是先执行一次tsc -b --force加清空 Turborepo 缓存的组合拳,确认从零开始能构建通过。之后再验证增量场景。这套流程虽然朴实,但能帮你把“代码问题”和“缓存问题”彻底分开,省下的排查时间远超那几分钟强制构建的成本。
搞明白了 composite 的硬约束和项目引用的构建顺序,跨包引用失败就没什么玄学了:让每个包老老实实成为独立编译单元,再让 Turborepo/Nx 把这类任务按正确依赖关系串起来。剩下的,就是遇到报错时先看看声明文件在不在,再跑一次--force,最后用--traceResolution让 TypeScript 把话说清楚。