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:

  1. Look at packages/app/tsconfig.json.
  2. See it references ../utils.
  3. Go check if packages/utils needs to be built (i.e., are its source files newer than its output files?).
  4. Build utils first if needed.
  5. 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

  1. The rootDir Trap: This is the big one. If you don’t set rootDir in your composite projects, TypeScript will use the longest common path of all your input files. If you have a stray .ts file 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.

  2. Implicit Dependencies: The build system only knows about the dependencies you explicitly list in references. If your app depends on utils and utils depends on shared-lib, but app’s config only references utils, then building app will not automatically build shared-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.

  3. 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 with references) to understand how all your projects fit together. This is what gives you seamless “Go to Definition” from app into utils. However, the actual building is done with tsc -b. It’s important to know these are two separate systems using the same configuration.

  4. Cleaning Outputs: You know how you sometimes just need to rm -rf dist to 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.