35.2 TypeScript Project References in a Monorepo
Right, let’s talk about Project References. This is TypeScript’s official, built-in answer to the question, “How do I make my monorepo not a complete nightmare to build?” It’s the feature that lets one TypeScript project (tsconfig.json) intelligently depend on another. It’s the difference between manually orchestrating a tsc --build in every package in the right order and just running one command from the root that figures it all out for you. It’s genuinely good stuff, but like most things in TypeScript, it has a few sharp edges you need to sand down.
The core idea is simple: you tell your consumer project where its dependencies are (the “references”), and you tell your dependency projects to emit declaration files and pre-build themselves (the “composite” flag). TypeScript’s build engine then uses this information to create a dependency graph and build things in the correct order. No more --watch juggling.
The Core Configuration
Let’s say you have a classic two-package monorepo: a utils library and an app that uses it. Here’s how you’d wire them up.
First, your packages/utils/tsconfig.json becomes a “composite” project. This is the magic flag that makes it reference-able.
// packages/utils/tsconfig.json
{
"compilerOptions": {
"composite": true, // <- The big one. Must be true.
"declaration": true, // <- Must be true for composite.
"declarationMap": true, // Highly recommended for debugging.
"rootDir": "./src", // Strongly recommended to avoid chaos.
"outDir": "./dist" // You want your builds in a neat folder, right?
},
"include": ["src/**/*"]
}
Notice that declaration: true is mandatory for composite. This makes sense—if another project is going to depend on you, it needs your .d.ts files to know what you’re about. If you forget this, TypeScript will yell at you, which is helpful.
Now, over in your consumer project, you add a references array to point to the dependency.
// packages/app/tsconfig.json
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
// ... other app-specific options
},
"references": [{"path": "../utils"}] // <- Point to the tsconfig of your dependency
}
The path is relative to this tsconfig.json file. This is why a monorepo with a consistent folder structure (packages/<name>) is so lovely.
Building the Whole Shebang
You don’t build these individually anymore. You use the --build (or -b) flag, which tells tsc to enter “build mode” and respect project references.
From the root of your monorepo, you can build everything:
tsc --build --verbose packages/app
The --verbose flag is your best friend. It tells you exactly what tsc is doing, which is invaluable when you’re debugging why it’s not doing what you thought it would. This command will:
- Look at
packages/app/tsconfig.json. - See it references
../utils. - Go check if
packages/utilsneeds to be built (i.e., are its source files newer than its output files?). - Build
utilsfirst if needed. - Then build
app.
You can also just run tsc --build from within the packages/app directory. The effect is the same. To build every project in your monorepo, you can create a root tsconfig.json that just has references to all your projects and run tsc --build from the root.
// tsconfig.json (at the monorepo root)
{
"files": [],
"references": [
{"path": "packages/utils"},
{"path": "packages/app"}
]
}
The Dev Loop: --build --watch
This is where Project References stop being merely “useful” and start being “magical”. Run this:
tsc --build --watch packages/app
Now, go change a file in packages/utils/src. Watch what happens. TypeScript will instantly rebuild only the utils project and then, crucially, only the parts of the app project that could possibly be affected by that change. It’s incremental builds across project boundaries. It’s fast. It’s what you deserve.
Common Pitfalls and Sharp Edges
The
rootDirTrap: This is the big one. If you don’t setrootDirin your composite projects, TypeScript will use the longest common path of all your input files. If you have a stray.tsfile in the root of your package, it will completely screw up your output directory structure, and your dependent projects will fail to find the declaration files. Just always set"rootDir": "./src". I’m serious.Implicit Dependencies: The build system only knows about the dependencies you explicitly list in
references. If yourappdepends onutilsandutilsdepends onshared-lib, butapp’s config only referencesutils, then buildingappwill not automatically buildshared-lib. You must reference all transitive dependencies you want to be built. This is a feature, not a bug—it gives you control—but it catches people off guard.Solution Style (
tsconfig.json) vs. Build Mode (tsc -b): Your IDE (like VS Code) uses the “solution style”tsconfig.json(the one at the root withreferences) to understand how all your projects fit together. This is what gives you seamless “Go to Definition” fromappintoutils. However, the actual building is done withtsc -b. It’s important to know these are two separate systems using the same configuration.Cleaning Outputs: You know how you sometimes just need to
rm -rf distto start fresh?tsc --build --clean [project]will do that for you, and it’s smart enough to clean the outputs of all your dependencies too. It’s a small thing, but it’s nice.
Project References are the foundation. They solve the core problem of building interdependent TypeScript packages. Are they perfect? No. The configuration can feel a bit verbose, and managing paths can be tricky. But they work, they’re official, and they don’t require adding another tool to your chain. For many monorepos, they are exactly the right tool for the job.