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, and lib-a (and consequently lib-b) will be recompiled.

    • Diagnosis: Run git diff packages/lib-a/src/index.ts or 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.
  • Changes to tsconfig.json: Any modification to packages/lib-a/tsconfig.json will invalidate its cache entry. This includes changes to compilerOptions like target, module, outDir, rootDir, composite, declaration, strict, skipLibCheck, as well as changes to include, exclude, or files.

    • Diagnosis: Run git diff packages/lib-a/tsconfig.json.
    • Fix: Re-run tsc -b packages/lib-b.
    • Why it works: The tsconfig.json file is a primary input to the compiler. Changes to its configuration directly alter the compilation process, so the cached output is no longer valid.
  • Changes to Dependency tsconfig.json: If lib-a has a tsconfig.json that is referenced by lib-b, and lib-a’s tsconfig.json changes, lib-b’s cache will also be invalidated. This is because lib-b’s compilation depends on the compiled output of lib-a, and that output is determined by lib-a’s tsconfig.json.

    • Diagnosis: If lib-b is recompiling unexpectedly, check git 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.
  • Changes to references in Dependent tsconfig.json: If packages/lib-b/tsconfig.json has its references array modified (e.g., adding or removing a dependency path), this will bust the cache for lib-b.

    • Diagnosis: Run git diff packages/lib-b/tsconfig.json and look at the references section.
    • Fix: Re-run tsc -b packages/lib-b.
    • Why it works: The references array defines the explicit build-time dependencies. Changing this alters what lib-b needs to build, invalidating its previous build state.
  • Changes to outDir or rootDir in the root tsconfig.json: When using tsc -b with a composite project, the root tsconfig.json (e.g., tsconfig.base.json or the root tsconfig.json that tsc -b points to) plays a role in orchestrating the build. If you change compilerOptions.outDir or compilerOptions.rootDir in the root configuration that tsc -b is 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 to outDir or rootDir.
    • 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.
  • The skipLibCheck Option: While skipLibCheck itself doesn’t invalidate the cache, changing its value will. If you change skipLibCheck from true to false (or vice-versa) in a tsconfig.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.json and look for skipLibCheck.
    • 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.
  • 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 .tsbuildinfo file is sensitive to the compiler version.

    • Diagnosis: Ensure all projects in the monorepo use the same typescript version in their devDependencies. Check npm ls typescript or yarn list typescript at the root.
    • Fix: Standardize on a single TypeScript version across the monorepo, often managed by a root package.json. Delete the .tsbuildinfo file (rm node_modules/.tsbuildinfo) and re-run tsc -b.
    • Why it works: The internal format and semantics of the .tsbuildinfo file can change between TypeScript versions, making it incompatible.
  • 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 run tsc -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 status to check for uncommitted changes.
    • Fix: Commit your changes or stash them. If the issue persists, consider deleting the .tsbuildinfo file and re-running tsc -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.

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.

Want structured learning?

Take the full Typescript course →