Understanding and Using TypeScript Project References

January, 7th 2025 5 min read

TypeScript Project References were introduced to solve a practical problem: large applications accumulate complexity quickly, and compiling everything from scratch becomes increasingly slow. Instead of working with a single massive tsconfig.json, Project References provide a predictable way to divide a project into smaller, independent units that can be built incrementally.

This article revisits the concept from the perspective of real project architecture. It explains the mechanics behind references, the reasoning for splitting configurations, and how to combine them with --build for production-grade performance. The goal is not to provide a checklist but to help understand how the feature fits into modern frontend and backend development workflows.


1. What Project References Actually Solve

In a typical codebase, different parts of the application evolve at different speeds. Some files rarely change, some change daily, and some are tightly coupled with specific environments such as frontend tooling or server infrastructure. Without references, the compiler treats everything as one unit even when only one part of the project was modified.

Project References address several long-standing needs:

Faster Builds Through Incrementality

TypeScript can skip entire parts of the project during compilation if their output is already up to date. This is especially valuable for monorepos and large libraries.

Clear Dependency Boundaries

When module A depends on module B, this relationship becomes explicit. TypeScript enforces the correct build order and prevents accidental circular dependencies.

Multiple tsconfig Files for Different Environments

Modern applications often combine browser code, server code, shared utilities, tests, scripts, and workers. Each part can have its own configuration tuned to its environment.

Repeatable and Predictable Builds

Project References make the compiler behave more like a traditional build system, offering deterministic output and verifiable build steps.


2. Defining References in tsconfig.json

At the heart of the system is the references field, which links one tsconfig.json file to another.

json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

Each referenced configuration becomes a distinct compilation unit. TypeScript builds them in the correct order, caches the result, and reuses it when no files have changed.

What each file represents:

  • tsconfig.app.json often handles client-side code compiled for browsers.
  • tsconfig.node.json targets server-side execution, sometimes with different module systems or output directories.

This separation reduces friction when working with tools like Vite, Webpack, Bun, or Node’s native ESM loader.


3. Why Large Projects Benefit From Multiple Configurations

Different Module Systems

Frontend code typically uses ES Modules. Backend code may use CommonJS, ESM, or a hybrid format depending on deployment requirements.

Environment-Specific Features

For example:

  • Browsers require DOM types.
  • Node requires filesystem and process types.
  • Shared TypeScript libraries require neither.

Having separate configurations allows each environment to rely only on the types and compiler settings it needs.

Isolation Improves Maintainability

When teams work on different parts of the same codebase, clear structure reduces confusion. Each package or module has a dedicated boundary defined by its configuration.


4. Example: Modular Project With References

Consider a project structured around independent modules:

bash
/my-project
  /module-a
    tsconfig.json
    index.ts
  /module-b
    tsconfig.json
    index.ts
  tsconfig.json

Root Configuration

json
{
  "files": [],
  "references": [
    { "path": "./module-a" },
    { "path": "./module-b" }
  ]
}

The root does not compile anything directly. Its primary purpose is orchestration.

Module A Configuration

json
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "../dist/module-a",
    "rootDir": "."
  },
  "include": ["index.ts"]
}

Module B Configuration (Depends on A)

json
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "../dist/module-b",
    "rootDir": "."
  },
  "include": ["index.ts"],
  "references": [{ "path": "../module-a" }]
}

TypeScript now understands that Module B relies on Module A and ensures the correct build order.


5. Incremental Compilation With —build

The --build (or -b) flag activates TypeScript’s composite build mode. This mode:

  • obeys the dependency graph
  • compiles only what changed
  • stores build metadata
  • skips stale modules automatically

To compile the entire project:

bash
tsc --build

To inspect each step in the process:

bash
tsc -b --verbose

This output is helpful when diagnosing slow builds or unexpected dependency behavior.


6. Composite Projects and Output Structure

To use references correctly, each referenced module must set:

json
"compilerOptions": {
  "composite": true
}

Composite mode enforces several rules:

  • The project must have an explicit outDir.
  • It must define rootDir when needed.
  • Configuration must be predictable so that TypeScript can manage incremental builds.

These constraints ensure that modules follow a clear contract, making large-scale builds reliable.


7. Typical Use Cases in Modern Development

Frontend + Backend Monorepos

A shared utilities package can be referenced by both the frontend and the backend without duplicating logic or configurations.

Multi-Package Libraries

Libraries like React, Angular, or many internal enterprise packages separate parts of the system into loosely coupled modules.

Microservices and API Layers

Each service may contain its own domain models but still depend on shared contracts or schema definitions.

Developer Tooling Projects

CLI tools often combine runtime code with build-time utilities, and splitting them avoids unnecessary recompilation.


8. Debugging Common Reference Issues

Circular Dependencies

If two modules reference each other, TypeScript cannot determine build order. This often means the architectural boundary needs reconsideration.

Incorrect OutDir or RootDir

Misconfigured paths lead to files overwriting one another or appearing in unexpected directories.

Forgetting composite: true

Without composite mode, TypeScript ignores references entirely.

Mixing Module Formats

Building ES Module output that imports CommonJS output (or vice versa) can produce runtime errors. Each module should declare its environment explicitly.


Conclusion

Project References allow TypeScript to scale from small tools to complex multi-package architectures. They formalize boundaries, improve build times, allow environment-specific configuration, and produce predictable, incremental builds.

For teams working across multiple environments or maintaining large applications, adopting Project References is not simply a convenience—it is a necessary step toward sustainable long-term development.

With a clear structure, explicit dependencies, and incremental builds, TypeScript becomes capable of handling projects of any size without slowing development down.