jsguides

Project References and Monorepos

As TypeScript projects grow, you eventually hit a wall: a single tsconfig.json becomes slow to compile, tests take forever to run, and managing dependencies feels chaotic. Project references and monorepos are the standard solution for scaling your TypeScript codebase while keeping build times manageable.

What are project references?

TypeScript’s project references let you split one big project into smaller, composable pieces. Instead of compiling everything with a single tsconfig.json, you create a graph of smaller projects that depend on each other.

Each sub-project has its own tsconfig.json with composite: true enabled. This tells TypeScript that the project can be referenced by others and that it will produce a .tsbuildinfo file for incremental builds.

// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

The key difference from a regular project is composite: true. This enables TypeScript’s build orchestration: when you build a project that depends on another, TypeScript automatically builds the dependencies first in the correct order.

Configuring References

To make one project reference another, add a references array to its tsconfig.json:

// packages/app/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../shared" },
    { "path": "../utils" }
  ]
}

When you run tsc —build on the app package, TypeScript:

  1. Checks which referenced projects need rebuilding
  2. Builds them in topological order
  3. Uses incremental build info to skip unchanged projects

This transforms a full rebuild from O(n) to O(1) for most changes; you only rebuild what’s actually modified.

The monorepo connection

Project references are the foundation of TypeScript monorepos. A monorepo is a single repository containing multiple packages or applications that share code. Several tools make this practical:

npm workspaces (Node.js native):

{
  "name": "my-monorepo",
  "workspaces": ["packages/*"]
}

npm workspaces ship with Node.js and require no extra tooling. They use the standard node_modules layout and resolve packages through the familiar require algorithm, making them the lowest-friction entry point for teams already on npm.

pnpm workspaces (faster, strict):

# pnpm-workspace.yaml
packages:
  - 'packages/*'

pnpm uses a content-addressable store and hard links instead of copying packages. This saves disk space and enforces strict dependency boundaries: a package can only import what it declares in its own package.json, catching accidental cross-package references at install time.

Yarn workspaces:

{
  "workspaces": ["packages/*"]
}

These tools handle package installation and linking. Project references handle TypeScript compilation. Together, they give you a unified build system.

For larger projects, specialized tools add capabilities:

  • Nx: Caching, distribution, and advanced task orchestration
  • Turborepo: Remote caching and pipeline optimization
  • Lerna: Package publishing and changelog management

Incremental builds and build info

The magic of project references is incremental compilation. When you build a composite project, TypeScript generates a .tsbuildinfo file tracking:

  • When each file was last compiled
  • Which files depend on which
  • The state of the output

On subsequent builds, TypeScript compares timestamps and only rebuilds what changed. This works both forward (when you modify a dependency) and backward (when you modify something that depends on a dependency).

# First build - full compilation
tsc --build ./packages/app

# Second build - only changed files
tsc --build ./packages/app
// File processing started...
// Processing 3 files...

The —build flag is required for project references to work. Running tsc without it treats the project as isolated.

Best Practices

Keep references shallow. A deep dependency graph increases build orchestration overhead. Favor a flat structure where possible.

Use a base config. Create a shared tsconfig.base.json that all projects extend:

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "skipLibCheck": true
  }
}

Output to a separate directory. Never mix source and output in the same directory when using project references. Use outDir to keep them separate.

Be careful with rootDir. When projects reference each other, rootDir must be set correctly or you’ll get path resolution errors. Each project should have a clear root.

Common Pitfalls

Forgetting composite: true. Without it, references won’t work and TypeScript won’t produce the build info needed for incremental compilation.

Circular references. TypeScript will error if you create circular dependencies between projects. Break the cycle by extracting shared code into a new project.

Mismatched TypeScript versions. All projects in a monorepo should use the same TypeScript version to avoid inconsistencies in types and compilation behavior.

OutDir conflicts. If two projects output to the same directory, you’ll get conflicts. Ensure each project has a unique output directory.

When to use project references

Not every project needs project references. Consider them when:

  • Build times exceed 30 seconds for incremental changes
  • You have multiple packages sharing code
  • You want independent test and build pipelines
  • CI/CD pipelines rebuild too much on each change

For small projects, a single tsconfig.json is simpler and sufficient. Project references add orchestration complexity; only adopt them when the scale justifies it.

Choose project boundaries carefully

A project reference works best when the package has a clear job and a stable public surface. Shared utilities, UI kits, and domain types are common candidates. If a package changes every day and nobody else depends on it, it may be too small to split out yet. The boundary should reduce build work, not create more noise.

Keep shared types stable

The more packages depend on shared types, the more careful you need to be about changes. A small edit in a shared project can ripple through the whole graph, so keep those types intentional and easy to review. That usually means fewer surprises when the build runs in a clean environment.

Build graphs, not piles

References work because the compiler knows the order in which packages depend on each other. Once that graph is clear, incremental builds become predictable. Think in terms of relationships, not just folders, and the repo becomes easier to scale without turning every change into a full rebuild.

Avoid cycles early

Cycles are a smell that the boundaries are not settled yet. If two packages keep reaching for each other’s internals, extract the shared pieces into a third package or rethink the split. Breaking the cycle early keeps the build graph honest and saves time later when the project is larger.

Keep public surfaces small

A project reference works best when the package exposes a small, steady surface. Shared types and reusable helpers are good candidates because other packages depend on them. If a package changes often and nobody else imports it, it may belong in the same project for now. The goal is to shorten builds, not create more folders for their own sake.

Let the build graph guide you

Once the package graph is clear, it becomes easier to decide what should build first and what can wait. Keep related projects connected through references rather than ad hoc imports. That makes incremental builds more predictable and helps new contributors see how the repository fits together.

Builds should stay incremental

The whole point of references is to keep the build work small. When a change touches only one project, the compiler should not have to reconsider the entire repo. That is what makes large TypeScript workspaces feel practical instead of heavy.

Keep sharing intentional

Shared code is useful when it truly belongs to more than one package. If a helper keeps moving between projects, it may be too common to live in only one place. Put shared logic where the dependency line makes the most sense, then let the reference graph do the rest.

See Also