TypeScript Project References: Stop Rebuilding Packages That Didn't Change
Project references split one tsconfig into a dependency graph so tsc --build can skip unchanged packages. A two-package example, verification steps, and the gotchas.
07 Oct 2026, 14:48 UTC

The symptom: one edit, the whole repo re-type-checks
In a monorepo with a single root tsconfig.json and a broad include, every type-check pass walks every package. You change a string constant in packages/core, run tsc --noEmit, and the compiler re-parses and re-checks the API server, the CLI, and the three packages that have nothing to do with your edit. The compiler isn't slow; it just has no idea that most of that work is unrelated.
Project references give it that information. They turn one flat config into a dependency graph, and tsc --build uses the graph to skip projects it can prove are still valid. The payoff is not a faster compiler — it's doing less work.
What a project reference actually is
A project reference is a pointer from one tsconfig.json to another. The referencing project declares that it consumes the output of the referenced one, not its source. TypeScript then knows two things: that the referenced project must be built first, and that the referencing project only needs to be rebuilt when the referenced project's public types change.
Two settings make this work:
composite: trueon any project that others reference. This marks the project as buildable and referenceable, and it impliesdeclaration: true— the project must emit.d.tsfiles, because those declarations are the contract consumers read.referenceson the consumer, listing the paths to the configs it depends on.
Incremental state lives in a .tsbuildinfo file per project. Its default location depends on your outDir/outFile settings, so set tsBuildInfoFile explicitly if you want it predictable in CI caches.
A two-package example
Assume a repo with packages/core and packages/app, where app imports from core. Each package gets its own config.
packages/core/tsconfig.json:
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src"]
}
packages/app/tsconfig.json — identical, plus one line:
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src"],
"references": [{ "path": "../core" }]
}
A root config ties them together. The empty files array is deliberate: without it, the root config tries to own input files and can fail with a "no inputs were found" error.
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/app" }
]
}
Run the build from the repository root, where TypeScript is installed as a dev dependency:
npx tsc --build --verbose
No special permissions are needed; this only writes to each project's outDir and its .tsbuildinfo. The verbose flag matters because it prints which projects were built and which were reported as up to date — that log is your evidence the graph is working.
Checking that work is actually skipped
- Run
npx tsc --build --verbosetwice with no edits. The second run should report every project as up to date and emit nothing. - Edit a file in
packages/app/srconly. Re-run. Expectpackages/coreto be reported up to date and onlypackages/appto rebuild. - Edit a file in
packages/core/src. Re-run. Now both rebuild —appdepends oncore's declarations, so a change there invalidates it. That is correctness, not a bug. - Confirm a
.tsbuildinfofile exists for each project at the path you configured.
If step 2 rebuilds everything, the graph isn't being read — usually because the root config is being passed to plain tsc instead of tsc --build.
Where project references bite
- Circular references are rejected. If
coreimports fromappandappreferencescore, the build fails. You have to redraw the boundary, which is often the real work. - Composite projects must emit. You cannot set
noEmit: truein a referenced project, and all input files must sit underrootDir. A shared file living outside the package breaks the build. pathsaliases don't rewrite runtime imports. References handle type resolution and build order; they do not make@acme/coreresolvable in emitted JavaScript. At runtime you still need workspace symlinks with apackage.jsonexportsfield pointing atdist, or a bundler alias.- Bundlers ignore references. Vite, Webpack, and esbuild follow their own resolution and won't read your graph. Keep
tsc --buildas a separate type-check step in CI rather than assuming a bundler run covers it. - Version floor. Project references require TypeScript 3.0 or later, and mixing a much older compiler in one workspace produces configuration errors rather than a clear message.
- Stale outputs. If you delete
distby hand, don't trust incremental state — runnpx tsc --build --cleanor--forceto reset, then rebuild.
Start with one boundary
Don't convert the whole repo at once. Pick the package with the fewest dependencies, mark it composite, add a single references entry from one consumer, and measure with tsc --build --verbose. If the log shows the referenced project being skipped on an unrelated edit, the boundary is drawn correctly and you can add the next one. If it doesn't, you've learned something about your dependency graph before you rewrote twenty configs.
The honest limitation: this only pays off when packages are genuinely separable. In a codebase where everything imports everything, the graph collapses into one node and you're back where you started — with more config files.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.