TypeScript in Monorepos: Using Project References Correctly
AI generated
<T>
type
TypeScript · Monorepo · Project References · Build Tooling
TypeScript in Monorepos: Using Project References Correctly
Composite builds, tsc -b, and clean package boundaries

Running TypeScript in a monorepo without project references means waiting for a full type check of the entire codebase on every change, with no real type safety enforced between packages. Project references, composite builds, and a shared base configuration create an explicit dependency graph, fast incremental builds, and reliable boundaries between packages, even as the number of packages grows.

17 min read composite · references · tsc -b pnpm Workspaces · Turborepo · Nx

1. Why Monorepos Need Their Own TypeScript Strategy

In a monorepo with multiple TypeScript packages, the codebase grows faster than the boundaries between packages stay clean. Without project references, the TypeScript compiler by default treats the entire repository as a single compilation context: any change in a deeply nested file can potentially trigger a type check of the whole project, even when only a single package is actually affected. With ten or twenty packages, this naive approach becomes noticeably slow, both in the editor via the TS language server and in the CI pipeline.

Project references solve this by turning each package into an independent TypeScript project unit, with its own tsconfig.json, its own build output, and an explicitly declared dependency graph to other packages. This lets the compiler know the order in which packages must be built, and it can reuse type information from already compiled declaration files instead of re-parsing source code from other packages. The result is a setup that scales with the number of packages instead of getting linearly slower.

2. Project References in Detail: composite, references, prepend

A package becomes a "composite project" once compilerOptions.composite is set to true in its tsconfig.json. composite automatically enforces several conditions: declaration must be enabled so other packages can consume type information without access to the source code, rootDir and include must be set so the compiler knows exactly which files belong to the project, and every referenced file must fall within that boundary. A package that sets composite: true can no longer freely reach into files outside its rootDir.

The actual link is made through the references array: { "path": "../core" } tells the compiler that this package depends on packages/core. When building the dependent package, tsc first checks whether core is up to date and builds it first if needed. The less commonly used prepend option inlines the compiled output of referenced projects directly into the output file, which is only relevant for outFile-based bundles and is practically never needed in modern ESM setups with separate output directories.


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

// File: packages/api/tsconfig.json
// api depends on core, so it lists core in "references"
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "tsBuildInfoFile": "./dist/.tsbuildinfo"
  },
  "include": ["src"],
  "references": [
    { "path": "../core" }
  ]
}

3. Composite Builds and Build Order with tsc -b

tsc -b, the compiler's build mode, differs fundamentally from a regular tsc invocation. Instead of compiling a single tsconfig.json, tsc -b reads the entire references graph, sorts the packages topologically, and builds them in exactly the order their dependencies require. A package is only rebuilt if its source files or one of its dependencies has changed since the last build. This state is tracked per package in a .tsbuildinfo file, which stores timestamps, file hashes, and the compiler options used.

In practice, a single command like npx tsc -b packages/api is enough to build the entire dependency tree correctly: TypeScript automatically builds packages/core first if api depends on it. The --force flag forces a full rebuild regardless of the cache state, --clean removes all build artifacts and .tsbuildinfo files, and --watch monitors all referenced projects simultaneously and rebuilds only the affected packages on changes, in the correct order.


# Build all packages in correct topological order,
# starting from the given project as the entry point
npx tsc -b packages/api

# Force a full rebuild, ignoring the .tsbuildinfo state
npx tsc -b packages/api --force

# Remove all build outputs and .tsbuildinfo files
npx tsc -b packages/api --clean

# Watch mode: rebuild only what changed, respecting the reference graph
npx tsc -b packages/api --watch

# Verbose output to see which projects were skipped as up to date
npx tsc -b packages/api --verbose

4. A Shared tsconfig.base.json for All Packages

Without a shared base configuration, the same block of compilerOptions repeats in every single package, which means any adjustment to strict rules or the target version has to be kept in sync across ten different files. A tsconfig.base.json at the repo root centrally defines all shared options such as target, strict, esModuleInterop, skipLibCheck, and moduleResolution. Every package extends this base with extends: "../../tsconfig.base.json" and only overrides package-specific values like outDir, rootDir, and tsBuildInfoFile.

One important detail: composite and declaration do not necessarily need to live in the base if not every package should be a composite project, for example a pure test or tooling package with no consumers of its own. A second, smaller intermediate layer such as a package-specific tsconfig.build.json can enable additional build-only options like declarationMap or sourceMap, while a tsconfig.json with test-relevant includes is kept separate for the editor and the test runner. This separation prevents test files from accidentally ending up in the production build.


// File: tsconfig.base.json (repo root)
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "composite": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true
  }
}

5. Path Aliases vs. Project References

Path aliases via the paths option in compilerOptions solve a different problem than project references: they only change how the compiler and the editor resolve an import path such as @mironsoft/core within a single TypeScript program. paths has no influence on the emitted JavaScript output, no effect on build order, and enforces no boundary between packages. A package can, in theory, reach through an alias into internal, non-exported files of another package, because paths is simply a file-path mapping, not a module boundary.

Project references solve the structural problem: composite project plus references forces a package to be consumed only through its actually exported declaration files, and the build fails if a package tries to import another one that isn't explicitly listed in references. In practice, teams combine both: paths for short, convenient import paths in the editor, project references for the actual build and type graph. Bundlers like esbuild or Vite resolve paths themselves at build time, but need a plugin like vite-tsconfig-paths to do so, since they don't natively respect tsconfig.json.

6. Declaration Files (.d.ts) and Output Structure

For a package in a monorepo to be consumable by another without re-parsing its source code, declaration: true must be set. The compiler then emits a .d.ts file alongside the compiled .js file, mirroring the same directory structure in outDir. Consuming packages read exclusively these declaration files, which significantly speeds up type checking because TypeScript no longer needs a full re-analysis of the foreign source code, only the already-resolved signatures.

declarationMap: true additionally generates .d.ts.map files, letting "Go to Definition" in the editor jump directly to the original .ts source file instead of the generated declaration, which noticeably improves the developer experience in a monorepo. In every package's package.json, the types field (or exports with a types condition) should point exactly to the emitted .d.ts file in the dist directory, not to the source file. A common mistake is pointing main and types in package.json at src instead of dist, causing consumers to import unbuilt source files and losing the entire benefit of the composite build.

7. Integrating pnpm Workspaces, Turborepo, and Nx

pnpm workspaces form the foundation for package management in the monorepo: through the workspace: protocol in package.json, packages reference one another with exact, locally symlink-based resolution, without pulling versions from the npm registry. TypeScript project references and pnpm workspaces solve different, complementary problems: pnpm makes sure node_modules is linked correctly, while project references make sure the compiler knows the build order and the type boundaries. Both configurations should be kept in sync; every dependency in package.json should also exist as a references entry.

Turborepo and Nx build on top of this structure by defining their own task graphs with dependsOn: ["^build"], meaning a package's build task only starts once all its dependencies have finished building. In practice, this mirrors exactly the order tsc -b already computes from the references graph, but with additional remote caching through a cloud service or a self-hosted cache backend. Nx can even infer the project graph automatically from import statements and reconcile it with the tsconfig structure, catching inconsistent references entries early.


// File: turbo.json (repo root)
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

8. Incremental Build Caching and Performance

The key building block for incremental builds is the .tsbuildinfo file that tsc -b writes per package into outDir. It contains hashes of all source files, the compiler options used, and the signatures of exported types. On every subsequent build, TypeScript compares these hashes: if neither a source file nor a referenced dependency has changed, the compiler skips the package entirely without even opening a file. incremental: true must be set alongside composite for this, though composite automatically implies incremental.

Turborepo and Nx extend this local caching with remote caching: a hash of source files, lockfile version, and compiler options is used as the cache key, and on a hit, the entire dist folder including .tsbuildinfo is downloaded from a shared cache instead of being rebuilt. This is especially effective in CI pipelines when multiple branches share the same unchanged package state. Important: if the .tsbuildinfo file is accidentally checked into the git repository instead of being added to .gitignore, this leads to inconsistent build states across developer machines, because absolute paths and timestamps are machine-specific.

9. Common Monorepo Pitfalls Compared

The most common mistake in growing monorepos is a circular dependency between packages: package A imports a type from package B, while B simultaneously imports something from A. TypeScript refuses to build in this case with the error "Project references may not form a circular graph" (TS6202). The fix is almost always the same: extract the commonly needed type or logic into a third, dependency-free package that both sides reference, instead of referencing each other.

A second, more subtle problem is inconsistent TypeScript versions between packages. If pnpm or npm hoists or installs different typescript versions from devDependencies in different packages, identical .d.ts files can be interpreted slightly differently depending on the compiler version used, leading to type errors that don't reproduce consistently. The fix: declare typescript as a single version in the root package.json, enforce it in pnpm with the pnpm.overrides field or tools like syncpack, and check in CI with tsc --version at a central point that all packages are actually using the same compiler version.


// File: packages/core/src/index.ts
// WRONG: core imports from api, but api already references core -> cycle
import type { ApiResponse } from '@mironsoft/api';
// error TS6202: Project references may not form a circular graph.

export interface CoreEntity {
  id: string;
}

// RIGHT: extract the shared type into a third, dependency-free package
// File: packages/shared-types/src/index.ts
export interface CoreEntity {
  id: string;
}

export interface ApiResponse<T> {
  data: T;
  meta: { requestId: string };
}

// packages/core and packages/api both reference packages/shared-types,
// but never reference each other -> no cycle
Aspect Without Project References With Project References (composite) Effect
Type check on change Full repo check Only affected packages via tsc -b Much faster feedback loops
Package boundaries Convention only (paths) Enforced by the compiler No accidental internal imports
Circular dependencies Only visible at runtime Build fails immediately with TS6202 Early, clear error message
CI caching No intermediate state, rebuilds everything .tsbuildinfo + remote cache Shorter CI run times
Editor performance One TS server checks everything References isolate the language server scope Responsive editor with many packages

In practice these pitfalls are often connected: a team that avoids circular references through clean package boundaries usually also has fewer problems with inconsistent TypeScript versions, because both disciplines benefit from the same underlying mindset, namely modeling dependencies explicitly instead of implicitly. Teams that consistently apply the recommendations from the table above gain both faster builds and more reliable type checking across the entire monorepo.

Mironsoft

TypeScript tooling, frontend architecture, and type-safe headless integrations

Need a TypeScript monorepo with a clean build architecture?

We structure existing or new monorepos with project references, set up composite builds with tsc -b, and integrate Turborepo or Nx for fast, cached CI pipelines, including a shared tsconfig.base.json and clean package boundaries.

Monorepo Audit

Analyze the existing tsconfig structure, find circular references and inconsistent TS versions

Project References Setup

Set up composite builds, tsc -b build order, and a shared base configuration

CI/CD Integration

Configure Turborepo or Nx with remote caching for fast, reproducible pipelines

10. Summary

TypeScript project references solve the core problem of growing monorepos: an explicitly declared, compiler-enforced dependency structure between packages instead of loose conventions. composite: true plus a references array turn every package into an independent build unit, tsc -b builds these units in topological order and automatically skips unchanged packages via the .tsbuildinfo file. A shared tsconfig.base.json keeps common compiler options centralized, while each package only sets outDir, rootDir, and tsBuildInfoFile individually.

Path aliases remain useful for short import paths in the editor, but they don't replace the structural safety net that project references provide. The biggest traps, circular references and inconsistent TypeScript versions between packages, can be avoided with clear rules: extract shared types into dependency-free packages and enforce a single TypeScript version across the entire repository. Combining these building blocks results in a monorepo that scales with the number of packages instead of getting slower with every build.

TypeScript in Monorepos: Project References - The Essentials at a Glance

Project References

composite: true plus a references array enforce an explicit, compiler-checked dependency graph between packages.

tsc -b Build Order

Builds packages in topological order, automatically skips unchanged packages via .tsbuildinfo.

Shared Base Configuration

Maintain tsconfig.base.json centrally, override only outDir, rootDir, and tsBuildInfoFile per package.

Common Pitfalls

Catch circular references early (TS6202), keep the TypeScript version in sync across all packages.

11. FAQ: TypeScript Project References in Monorepos

1What are TypeScript project references?
A feature that splits a monorepo into multiple independent, composite projects with an explicitly declared dependency graph, so the compiler knows build order and type boundaries.
2What exactly does composite: true do?
Enforces declaration: true, fixed rootDir/include boundaries, and automatically enables incremental builds. Makes the package referenceable by other projects.
3How does tsc -b differ from regular tsc?
Reads the entire references graph, sorts it topologically, and only rebuilds changed packages. Regular tsc always compiles just a single tsconfig.json.
4Path aliases vs. project references?
paths only changes import resolution with no real boundary. Project references enforce a compiler-checked module boundary and the build order.
5"Circular graph" error (TS6202)?
Two packages reference each other. Fix: extract the shared code into a third, dependency-free package that both sides reference.
6How do I set up tsconfig.base.json?
Create it at the repo root with shared compilerOptions. Every package extends it and only overrides outDir, rootDir, and tsBuildInfoFile.
7declaration vs. declarationMap?
declaration generates the .d.ts files for other packages. declarationMap additionally generates .d.ts.map so Go to Definition jumps to the real .ts source.
8Integration with Turborepo or Nx?
dependsOn: ['^build'] mirrors the references graph. Both tools add remote caching on top of .tsbuildinfo for faster CI runs.
9Type errors only in CI, not locally?
Usually inconsistent TypeScript versions between packages. Declare a single version in the root and enforce it with pnpm.overrides or syncpack.
10What belongs in .gitignore?
All dist directories and all .tsbuildinfo files, since they contain absolute paths and machine-specific timestamps.