Right, so your project has grown. It happens to the best of us. One day you’re building a neat little library, the next you’re staring at a sprawling monolith of interdependent code. tsc is starting to groan under the weight, and the thought of a full rebuild every time you change a single utility file is giving you a migraine. This is where Project References come in. They’re TypeScript’s official answer to “how do I split this behemoth without losing my mind?”

The concept is brilliantly simple: you break your single project into smaller, interdependent projects. Each gets its own tsconfig.json. You then tell the main project about these smaller “reference” projects. The compiler, suddenly feeling much smarter, can now understand the dependency graph. It will only rebuild what’s changed and what depends on that change. It’s like giving tsc a map instead of making it guess.

The Basic Setup: How to Wire It All Up

Let’s say you have a client/ app and a shared/ utility library. The client depends on shared. Here’s how you’d set it up.

First, in your shared/tsconfig.json, you must do two things: set "composite": true and specify "outDir". The composite flag is the secret handshake. It tells TypeScript, “Hey, this isn’t just any old project; it’s a reference-able one that needs to keep extra metadata around for its friends.” The outDir is non-negotiable because the compiler needs to know where to put the build artifacts that other projects will import from.

// shared/tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "composite": true
  }
}

Now, over in your client/tsconfig.json, you add a top-level references field. This is where you point to the shared project. The path is relative to this config file.

// client/tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "strict": true,
    "outDir": "./dist"
  },
  "references": [
    { "path": "../shared" }
  ]
}

Building with --build: The Magic Button

This is the most common “gotcha.” You can’t just run tsc from the client directory anymore. If you do, it’ll look at the reference, see the pre-built output of shared doesn’t exist (or is outdated), and throw a polite but firm error. This is TypeScript refusing to guess, and I respect it for that.

You must use the build mode: tsc --build (or tsc -b). This isn’t just a compiler; it’s a project builder. It looks at the reference graph, checks what’s already been built, and only compiles what is necessary. To build the entire project from the root, you can use a root tsconfig.json that just lists references.

# Build the client project and all its dependencies
tsc --build client

# Build from the root, building all referenced projects
tsc --build

# Do a clean build (nuke the output directories)
tsc --build --clean

The .d.ts Requirement: Your Contract

Notice I snuck "declaration": true into the shared config. This is critical. When project client depends on shared, it isn’t importing the source .ts files from shared/src. Oh no, that would be too straightforward. Instead, it imports the output: the compiled JavaScript from shared/dist and, more importantly, the type definitions from shared/dist.

This is the genius and the curse of it. Your shared project must produce its own published API—its .d.ts files. This enforces a fantastic discipline: your projects become proper consumers of each other’s outputs, not their internals. It mimics the real world, where you’d install shared as an npm package. It stops you from accidentally depending on some deeply internal file path that was never meant to be public.

The Dev Inner Loop: --watch and --incremental

The real payoff is in development. Run tsc --build --watch from your root, and behold the glory. Change a file in shared? It will build shared, then immediately only rebuild the parts of client that are affected. It’s intelligent incremental builds done right.

The build mode also handles --incremental file tracking automatically and more reliably than in a single project. It stores this information in a .tsbuildinfo file next to your output. You can safely add this to your .gitignore; it’s just a cache to make the next build faster.

The Rough Edges and Pitfalls

It’s not all rainbows. Here’s where they get you:

  1. Source Navigation: Jumping-to-source in your editor can get confused. You click on an import from shared and your editor might try to take you to the .d.ts file in dist/ instead of the original source. Most modern editors handle this okay, but be prepared to occasionally fight it.
  2. The Root tsconfig.json Trap: You might be tempted to put all your common compiler options in a root tsconfig.json and extend it. This works… until it doesn’t. Each project’s tsconfig.json is its own sovereign entity. If you extend a base config, paths in that base config (like "outDir") are relative to the inheriting config file, not the base one. This will cause you immense pain. My advice? Don’t bother. A little duplication is better than a lot of broken builds. Use a script to sync common options if you must.
  3. Dependency Quagmire: Circular references are forbidden, and thank goodness for that. But deep, complex dependency graphs can still make your build logic tricky to reason about. Keep it as simple as you possibly can.

Is it more work to set up than a single project? Absolutely. Is it worth it the moment your project scales beyond a certain point? Unquestionably. It transforms TypeScript from a sluggish type checker into a powerful, incremental build system. It’s the closest thing we have to a module system for the build itself.