Project references in TypeScript monorepos are a game-changer for build times, but they only work as advertised if you understand how the build cache actually functions.
Let’s see it in action. Imagine you have two packages, lib-a and lib-b, where lib-b depends on lib-a.
packages/lib-a/src/index.ts:
export const message = "Hello from lib-a!";
packages/lib-a/tsconfig.json:
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"outDir": "dist",
"rootDir": "src",
"composite": true,
"declaration": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
packages/lib-b/src/index.ts:
import { message } from "lib-a";
console.log(message);
packages/lib-b/tsconfig.json:
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"outDir": "dist",
"rootDir": "src",
"composite": true,
"declaration": true,
"strict": true,
"skipLibCheck": true
},
"references": [
{ "path": "../lib-a" }
],
"include": ["src"]
}
Now, if you run tsc -b packages/lib-b from your monorepo root, TypeScript will first compile lib-a (if it hasn’t been compiled already) and then compile lib-b, respecting the dependency.
The magic of the build cache happens when you run tsc -b packages/lib-b again. If neither packages/lib-a/src/index.ts nor packages/lib-a/tsconfig.json has changed, TypeScript will not recompile lib-a. Instead, it will use the pre-compiled output from the previous build, dramatically speeding up subsequent builds. The cache is essentially a directory (by default node_modules/.tsbuildinfo) that stores metadata about previous compilations, including file hashes and output artifacts. When you run tsc -b, it checks this metadata. If the inputs (source files, tsconfig) haven’t changed, it reuses the cached outputs.
The core problem most people encounter is not understanding what actually invalidates the cache. It’s not just source files; it’s any input that influences the compilation output.
Here are the common culprits that will bust your build cache:
-
Changes to Source Files: This is the most obvious one. Modify
packages/lib-a/src/index.ts, andlib-a(and consequentlylib-b) will be recompiled.- Diagnosis: Run
git diff packages/lib-a/src/index.tsor inspect the file content. - Fix: Re-run
tsc -b packages/lib-b. - Why it works: The file hash stored in the build cache will no longer match the current file content, forcing a recompile.
- Diagnosis: Run
-
Changes to
tsconfig.json: Any modification topackages/lib-a/tsconfig.jsonwill invalidate its cache entry. This includes changes tocompilerOptionsliketarget,module,outDir,rootDir,composite,declaration,strict,skipLibCheck, as well as changes toinclude,exclude, orfiles.- Diagnosis: Run
git diff packages/lib-a/tsconfig.json. - Fix: Re-run
tsc -b packages/lib-b. - Why it works: The
tsconfig.jsonfile is a primary input to the compiler. Changes to its configuration directly alter the compilation process, so the cached output is no longer valid.
- Diagnosis: Run
-
Changes to Dependency
tsconfig.json: Iflib-ahas atsconfig.jsonthat is referenced bylib-b, andlib-a’stsconfig.jsonchanges,lib-b’s cache will also be invalidated. This is becauselib-b’s compilation depends on the compiled output oflib-a, and that output is determined bylib-a’stsconfig.json.- Diagnosis: If
lib-bis recompiling unexpectedly, checkgit diff ../lib-a/tsconfig.json. - Fix: Re-run
tsc -b packages/lib-b. - Why it works: The dependency graph means that a change in a dependency’s configuration ripples upwards, invalidating dependent projects’ caches.
- Diagnosis: If
-
Changes to
referencesin Dependenttsconfig.json: Ifpackages/lib-b/tsconfig.jsonhas itsreferencesarray modified (e.g., adding or removing a dependency path), this will bust the cache forlib-b.- Diagnosis: Run
git diff packages/lib-b/tsconfig.jsonand look at thereferencessection. - Fix: Re-run
tsc -b packages/lib-b. - Why it works: The
referencesarray defines the explicit build-time dependencies. Changing this alters whatlib-bneeds to build, invalidating its previous build state.
- Diagnosis: Run
-
Changes to
outDirorrootDirin the roottsconfig.json: When usingtsc -bwith a composite project, the roottsconfig.json(e.g.,tsconfig.base.jsonor the roottsconfig.jsonthattsc -bpoints to) plays a role in orchestrating the build. If you changecompilerOptions.outDirorcompilerOptions.rootDirin the root configuration thattsc -bis using, it can invalidate caches because the location of intermediate build artifacts might change.- Diagnosis: If a full rebuild is happening unexpectedly, check
git diff tsconfig.base.json(or your root tsconfig) for changes tooutDirorrootDir. - Fix: Re-run
tsc -b. - Why it works: These options define where build outputs are placed. A change here means the previously cached outputs are in the wrong place, necessitating a rebuild.
- Diagnosis: If a full rebuild is happening unexpectedly, check
-
The
skipLibCheckOption: WhileskipLibCheckitself doesn’t invalidate the cache, changing its value will. If you changeskipLibCheckfromtruetofalse(or vice-versa) in atsconfig.json, the compiler’s behavior regarding type checking of external libraries changes, and the cache will be busted.- Diagnosis: Run
git diff packages/lib-a/tsconfig.jsonand look forskipLibCheck. - Fix: Re-run
tsc -b packages/lib-b. - Why it works: This option fundamentally alters the compiler’s analysis of external types, meaning the cached output is based on a different type-checking regime.
- Diagnosis: Run
-
TypeScript Version Mismatches: If different projects in your monorepo are using incompatible versions of the TypeScript compiler, or if the version used to create the cache is different from the version reading it, cache invalidation can occur unexpectedly. The
.tsbuildinfofile is sensitive to the compiler version.- Diagnosis: Ensure all projects in the monorepo use the same
typescriptversion in theirdevDependencies. Checknpm ls typescriptoryarn list typescriptat the root. - Fix: Standardize on a single TypeScript version across the monorepo, often managed by a root
package.json. Delete the.tsbuildinfofile (rm node_modules/.tsbuildinfo) and re-runtsc -b. - Why it works: The internal format and semantics of the
.tsbuildinfofile can change between TypeScript versions, making it incompatible.
- Diagnosis: Ensure all projects in the monorepo use the same
-
External File Changes Not Tracked by Git: If a file that influences compilation (a source file, a
tsconfig.json, or even a file imported by a dependency) is modified but not committed to version control, and then you runtsc -b, the cache might appear to be valid when it shouldn’t be. Conversely, sometimes uncommitted changes might cause unexpected rebuilds if the cache generation process itself is sensitive to file modification times or other filesystem metadata not captured by simple content hashes.- Diagnosis: Run
git statusto check for uncommitted changes. - Fix: Commit your changes or stash them. If the issue persists, consider deleting the
.tsbuildinfofile and re-runningtsc -b. - Why it works: The build cache relies on consistent inputs. Uncommitted changes introduce an inconsistent state between your working directory and what Git (and potentially the cache) expects.
- Diagnosis: Run
The most common reason for unexpected cache invalidation is a change in a tsconfig.json file, particularly one of the referenced projects, that you might have forgotten about.
The next problem you’ll likely encounter is understanding how to configure incremental builds for your entire monorepo, not just individual projects.