TypeScript Composite Projects: Stop Waiting for Full Rebuilds

You have a 300-file TypeScript codebase. You change one utility function. tsc rebuilds everything — every import chain, every type declaration, every file. Two minutes later you can test your three-line change. Sound familiar?

This is the default TypeScript experience for anything beyond a small app, and it’s genuinely painful once a project grows. The tooling has had a solution since TypeScript 3.0: composite projects paired with incremental compilation. Most teams ignore it until the pain becomes unbearable, then wish they’d set it up from day one.

This article is that setup guide. By the end you’ll have a working composite project structure, understand exactly what .tsbuildinfo files do, and know the common ways this system breaks so you won’t hit them.

Official docs and source: https://github.com/microsoft/TypeScript


Why tsc is slow by default

The TypeScript compiler is a full-program analyzer. It reads your entry points, follows every import, resolves every type, and type-checks the entire connected graph before emitting anything. It does this from scratch on every invocation unless you explicitly tell it not to.

For a 50-file project this is fine. For 500 files it starts to sting. For a monorepo with shared packages and multiple applications, you’re waiting for work that has already been done — unchanged packages getting rebuilt because the compiler doesn’t know they haven’t changed.

There are two complementary mechanisms to fix this:

  1. Incremental compilation — the compiler stores a snapshot of its last build and only rechecks files that have actually changed.
  2. Composite projects (project references) — you split the codebase into discrete TypeScript projects that each compile independently, and the build system understands the dependency graph between them.

They work well separately. Together they’re the reason large TypeScript monorepos stay buildable.


Incremental compilation: the quick win

You can drop incremental mode into any existing project with two lines in tsconfig.json:

{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo"
  }
}

That’s it. On the next tsc run, TypeScript writes a .tsbuildinfo file alongside your output. On every subsequent run it reads that file, diffs it against the current source, and only recompiles what’s stale.

The .tsbuildinfo file is a JSON blob — large, not human-readable in any useful sense. Don’t edit it. Don’t commit it to version control (add it to .gitignore). It’s a cache artifact.

The speedup is real. A project that takes 45 seconds cold will often rebuild in 3–4 seconds when only a handful of files changed. The bigger the codebase, the better the ratio.

Gotcha #1: If you move or rename tsBuildInfoFile without deleting the old one, the compiler creates a fresh cache from scratch. Not harmful, just confusing when you expect the speed boost.

Gotcha #2: The incremental cache is keyed on your tsconfig.json options. Change target, strict, or any other compiler option and TypeScript will discard the cache. This is correct behavior, but it catches people off guard.


Composite projects: the architectural fix

Incremental compilation is local to a single tsconfig.json. Composite projects go further — they let you break a large TypeScript codebase into multiple projects with explicit dependency edges between them.

A composite project must have three properties in its tsconfig.json:

{
  "compilerOptions": {
    "composite": true,       // marks this as a referenceable project
    "declaration": true,     // must emit .d.ts files
    "declarationMap": true   // optional but strongly recommended
  }
}

declaration: true is mandatory because when another project references this one, it reads the emitted .d.ts files instead of the source TypeScript. That’s the entire mechanism — the consuming project never re-type-checks the dependency’s source, it just reads the declarations.

declarationMap: true writes .d.ts.map files so that IDE "go to definition" jumps through to the original .ts source instead of landing on a generated declaration. Without this, working across project boundaries in VS Code gets annoying fast.


A concrete monorepo example

Here’s a realistic layout — a monorepo with a shared utilities package, a core library, and an application that uses both:

my-monorepo/
├── tsconfig.base.json
├── packages/
│   ├── utils/
│   │   ├── tsconfig.json
│   │   └── src/
│   ├── core/
│   │   ├── tsconfig.json
│   │   └── src/
│   └── app/
│       ├── tsconfig.json
│       └── src/
└── tsconfig.json  ← the root solution file

tsconfig.base.json — shared compiler options, not a project itself:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist"
  }
}

packages/utils/tsconfig.json — a leaf project, no dependencies:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src/**/*"]
}

packages/core/tsconfig.json — depends on utils:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../utils" }
  ]
}

packages/app/tsconfig.json — depends on both:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../utils" },
    { "path": "../core" }
  ]
}

Root tsconfig.json — the solution file, also called the "build root":

{
  "files": [],
  "references": [
    { "path": "packages/utils" },
    { "path": "packages/core" },
    { "path": "packages/app" }
  ]
}

The root tsconfig has "files": [] — it doesn’t compile anything directly. Its only job is to list all the sub-projects so tsc -b knows what to build and in what order.


Building with tsc --build

Regular tsc doesn’t understand project references. You need the --build (or -b) flag:

# Build everything from the root
tsc --build

# Build in watch mode
tsc --build --watch

# Force a full rebuild, ignoring cached state
tsc --build --force

# See what would be built without actually building
tsc --build --dry

# Delete all build outputs (dist/, .tsbuildinfo files)
tsc --build --clean

tsc -b reads the dependency graph, builds in the correct order, and skips any project whose output is already up to date. If you change a file in utils, it rebuilds utils, then rebuilds core and app because they reference utils. It does not rebuild packages that didn’t change.

This is the key difference from monorepo tools that rely on file hashing at the shell level — TypeScript does the dependency tracking itself, at the type level.


package.json wiring in a monorepo

For Node.js packages you need to tell the module resolver where to find the compiled output. If you’re using npm/yarn/pnpm workspaces:

packages/utils/package.json:

{
  "name": "@myapp/utils",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

When core does import { something } from '@myapp/utils', the resolver finds the workspace symlink, which points to packages/utils, then reads the exports map and resolves to dist/index.js. TypeScript reads the .d.ts alongside it.

Gotcha #3: If you haven’t run tsc -b at least once, the dist/ folder doesn’t exist, workspace resolution fails, and you get confusing "module not found" errors. This trips up new contributors constantly. Put tsc -b in your prepare script.


Watch mode in development

Running tsc -b --watch at the repo root starts a persistent process that watches all projects. When you save a file it:

  1. Identifies which project owns that file
  2. Rebuilds that project incrementally
  3. Checks whether any downstream projects are now stale
  4. Rebuilds them in dependency order

For active development this is usually fast enough. For hot-reload workflows (Next.js, Vite, ts-node with --watch) you typically don’t use tsc -b --watch directly — those tools have their own transpilation pipelines. In that case you might run tsc -b --watch --noEmit in a separate terminal purely to get type errors surfaced continuously, while letting the dev server handle actual transpilation.


Gotchas and known rough edges

Gotcha #4: Circular references are a hard error. If core references utils and utils references core, tsc -b refuses to build with an error. This is actually a feature — circular dependencies between packages are a design smell that should be resolved, not worked around. The compiler forces you to notice them.

Gotcha #5: paths aliases don’t play well with composite projects. If you’re using "paths": { "@utils/*": ["packages/utils/src/*"] } in your tsconfig and migrating to composite projects, you need to replace those aliases with actual references and proper package.json exports. Paths aliases bypass the declaration-reading mechanism and often cause stale-cache issues.

Gotcha #6: The root tsconfig is not a normal tsconfig. It has "files": [] — it includes no source files of its own. Some tools (ts-jest, eslint with @typescript-eslint, ts-node) try to use the tsconfig they find and get confused by an empty files list. You typically need to point those tools at the specific sub-project tsconfig, not the root.

Gotcha #7: outDir must be set on composite projects. TypeScript requires that composite projects specify an outDir to ensure build artifacts are separate from source. If you’re currently dumping .js files next to .ts files, you’ll need to restructure before enabling composite.

Gotcha #8: Stale cache after git operations. When you git checkout a branch where a dependency package was at a different state, the .tsbuildinfo file may be stale relative to the actual source. tsc -b uses mtimes and content hashes to detect this, but in some CI environments (where mtimes don’t survive correctly) you may need tsc -b --force or a clean build step.


CI configuration

A typical CI pipeline for a composite project:

# .github/workflows/build.yml
name: Build

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      # Cache TypeScript build artifacts between runs
      - uses: actions/cache@v4
        with:
          path: |
            packages/*/dist
            packages/*/.tsbuildinfo
          # Cache key includes tsconfig and source hashes
          key: tsbuild-${{ hashFiles('**/tsconfig*.json', 'packages/*/src/**/*.ts') }}
          restore-keys: tsbuild-

      - run: npx tsc --build

      - run: npm test

The cache on dist/ and .tsbuildinfo files means CI can skip rebuilding unchanged packages between runs — same benefit as local incremental builds, but across pipeline runs.

One practical note: don’t cache only .tsbuildinfo without also caching dist/. The build info file references output files and will declare everything stale if the actual output is missing.


Type-checking without emitting

In some workflows — particularly with bundlers like Webpack, Rollup, or Vite that handle transpilation — you want TypeScript purely for type checking. You don’t need it to emit JavaScript. The pattern is:

tsc --build --noEmit

This still uses all the project reference and incremental machinery, but writes no output files. The .tsbuildinfo files are still written and used (that’s how it stays fast on subsequent runs). It’s an excellent CI gate — fast, catches real type errors, doesn’t interfere with your bundler’s output.


When composite projects aren’t the answer

A few situations where this tooling doesn’t help much:

  • Single small project under ~100 files. Incremental mode alone is enough; the overhead of splitting into composite projects isn’t worth it.
  • Projects using isolatedModules: true with a fast transpiler (swc, esbuild). If you’re using these tools for day-to-day development and tsc --noEmit as a check, incremental mode still helps but composite project splitting has diminishing returns.
  • Projects that change everything all the time. If your shared packages see heavy daily churn, you won’t reap large incremental savings. Composite projects shine when packages have stable interfaces.

The real payoff is a multi-package monorepo with some packages that change rarely — a design system, a utility library, a shared config package. Those compile once and stay compiled until their own source changes.


Summary

Incremental compilation is a two-line change with no architectural implications — add it to any project right now. Composite projects require deliberate structure, but give you per-package build isolation, explicit dependency tracking, and the ability to skip rebuilding anything that hasn’t changed.

The setup cost is a few hours of restructuring tsconfig.json files. The payoff is getting seconds-long rebuilds on a codebase that used to take minutes, and that payoff compounds as the project grows.

Set tsBuildInfoFile explicitly, keep it inside outDir, exclude it from version control, and point CI at the composite build cache. Everything else follows naturally from there.

👁 Views: 225,562 · Unique visitors: 59,958