26.3 Project References for Large Monorepos
Right, so your monorepo has gotten big. The node_modules directory has its own gravitational pull, running tsc feels like you’re asking your laptop to calculate the meaning of life, and you’re pretty sure you just saw the progress bar actually get slower. Welcome. This is where TypeScript’s Project References come in, and they are about to become your new best friend. They’re not magic, but they’re the closest thing we have to a free lunch in the TypeScript world.
The core problem is simple: TypeScript is re-checking and re-emitting code it already knows is correct. Your shared-utils package hasn’t changed in a week, but every time you tweak your web-app, tsc is painstakingly re-analyzing all 300 files in shared-utils just to make sure your one-line change didn’t somehow break the laws of physics. It’s absurdly wasteful.
Project References fix this by allowing you to structure your monorepo into a graph of TypeScript projects that know about and depend on each other. The compiler can then do two brilliant things: it can build dependencies first and reuse their output, and it can perform incremental type checking based on the actual shape of what changed.
The Basic Setup: tsconfig.json Changes
You’ll need two types of tsconfig.json files: one for the “library” projects (your dependencies, like shared-utils) and one for the “application” projects (the things that use them, like web-app).
First, for your library (packages/shared-utils/tsconfig.json), you enable composite and declarationMap. This tells TypeScript, “Hey, this project is meant to be a building block for others, and I need you to output the necessary metadata (.d.ts files and .d.ts.map files) so other projects can understand and use it efficiently.”
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "./src",
"outDir": "./dist",
// ... your other sensible options
},
"include": ["src/**/*"]
}
Now, for your application (apps/web-app/tsconfig.json), you add a references array. This is the dependency graph. You point it to the tsconfig.json of the library. Notice we also set prepend here—we’ll get to that in a second.
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
// ... your other options
},
"references": [
{ "path": "../../packages/shared-utils" }
],
"include": ["src/**/*"]
}
Building The Graph: Using tsc --build
This is the most important part. You don’t just run tsc anymore. You run tsc --build (or tsc -b for the lazy typists among us). This command is the brains of the operation.
When you run tsc -b from your web-app directory, it does this:
- It looks at the
referencesinweb-app/tsconfig.json. - It goes to each referenced project (
shared-utils) and checks if it needs to be rebuilt (have any of its source files changed since its last build?). - If it does, it builds that project first, creating the
.d.tsfiles and.jsfiles in itsdistdirectory. - It then reuses those pre-built
.d.tsfiles for type checkingweb-app. It doesn’t re-parse the source files ofshared-utils; it just leans on the output that already exists. This is the huge win.
The --prepend option in a reference allows for output concatenation, which is crucial for things like browsers. It’s a questionable choice of name—“prepend” makes it sound like it’s just sticking it at the top, but it’s actually doing a full topological sort and bundling the outputs in the correct order. If your web-app reference had "prepend": true on shared-utils, the final web-app/dist/index.js would have the compiled code from shared-utils prepended to its own code.
The Dev Loop: --watch Mode and --incremental
This is where the magic truly happens for development. Run tsc -b --watch. Now the compiler understands the entire graph of your project. Change a file in shared-utils? It will rebuild shared-utils and then only re-type-check the parts of web-app that could possibly be affected by that change. The feedback loop becomes incredibly fast. It feels like cheating. Combine this with --incremental (which caches information about the state of your project between runs) and you’ve just shaved minutes off your day.
Common Pitfalls and Sharp Edges
This power comes with a few gotchas. The designers were smart, but they didn’t make it foolproof.
- The Dependency Dance: The most common error is a missing or circular reference. Your build will fail with a wonderfully opaque error message if project A depends on B, which somehow also depends on A. TypeScript will just give up. You have to untangle that mess yourself.
- You MUST Emit Declarations: For a project reference to work, the referenced project must have
composite: true,declaration: true, and it’s highly recommended to havedeclarationMap: truefor a good debugging experience. If you forget,tsc -bwill yell at you, which is better than silently doing the wrong thing. - Pathing is a Pain: Getting the
referencespathcorrect can be annoying. It’s relative to thetsconfig.jsonfile you’re building from. Pro tip: use a helper like@manypkg/get-packagesor a simple script to generate thesetsconfigfiles if you have a huge number of packages. - It’s Not a Bundler: Remember, this is a compilation strategy, not a deployment strategy. You still need
webpack,rollup, orviteto actually bundle your application for the browser. Project References just make the type-checking and emitting part of that process vastly more efficient.