Why path aliases make type checking slow in a large Nx workspace

In a typical Nx workspace, a root tsconfig.base.json defines a paths map that points each import alias, such as @acme/orders, at a library's source entry file, usually something like libs/orders/src/index.ts. Every project's tsconfig.json extends that base file. Imports resolve cleanly and editors navigate across libraries without a build step, which is why this setup became the default.

The cost shows up at type-check time. When tsc checks an application, it follows each aliased import into the library's source files and adds them to the same program. Those libraries import other libraries, so the program keeps growing until it covers most of the dependency tree. The compiler has no concept of a project boundary here. It sees one large set of .ts files. If ten applications depend on a shared core library, core is parsed and checked ten times, once inside each program. An --incremental build info file only helps that one program on its next run. Nothing is shared between programs.

Project references give tsc the boundaries it lacks. Each library sets composite: true, which requires declaration output, and consumers list their dependencies in a references array. A dependent project then reads the library's emitted .d.ts files instead of re-checking its source, and an up-to-date library is skipped entirely.

Backend and platform engineers feel this most. They own workspaces with dozens or hundreds of projects, and they run typecheck in CI on every change, so repeated checking of shared libraries adds directly to pipeline time.

// libs/core/tsconfig.json (a shared library)
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    // Marks this as a buildable project boundary that tsc can cache and skip
    "composite": true,
    // composite requires declaration output; consumers read these .d.ts files
    "declaration": true,
    "declarationMap": true,
    "rootDir": "src",
    "outDir": "../../dist/out-tsc/libs/core"
  },
  "include": ["src/**/*.ts"]
}

// apps/api/tsconfig.json (one of many apps that depend on core)
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "../../dist/out-tsc/apps/api"
  },
  "include": ["src/**/*.ts"],
  // Declares the dependency explicitly. Imports of @acme/core now resolve to
  // core's emitted .d.ts instead of pulling its source into this program.
  "references": [
    { "path": "../../libs/core" }
  ]
}

// Type-check in build mode so up-to-date referenced projects are skipped:
//   npx tsc --build apps/api/tsconfig.json

A library tsconfig with composite and declaration output, plus an app tsconfig that lists it in references, so tsc --build checks core once and reuses its .d.ts files across every dependent app.

How tsc build mode and incremental type checking work

tsc --build (or tsc -b) treats each tsconfig.json listed under references as a separate compilation unit. It reads the reference graph, sorts it topologically, and processes projects leaf-first, so every project is checked only after the projects it depends on. Each referenced project must set composite: true. That setting forces declaration emit, so dependents type-check against the emitted .d.ts files rather than re-parsing and re-checking the dependency's source.

Before it rebuilds anything, tsc decides whether the project is already up to date. The .tsbuildinfo file stores the state of the last build: the compiler options in use, a version or hash for each input file, the diagnostics that were reported, and a signature of the declaration output. A project counts as up to date when its inputs and options match that record, its outputs exist, and none of its references has produced newer declarations. When all of those hold, tsc skips the project without type checking it.

When a project does rebuild, tsc compares the new declaration signature with the stored one. Suppose you change a function body or a private helper. The emitted .d.ts stays the same, so the dependents remain valid and are not re-checked. Suppose instead you add a parameter to an exported function. The declarations change, and every downstream project has to be checked again.

This is why the shape of your graph matters as much as using build mode at all. If a shared types library is imported by nearly every project, a change to its public API invalidates most of the graph. Keep exported surfaces small and stable. Separate fast-moving code from the declarations that many projects consume.

 tsc -b checks leaf-first; dependents read .d.ts output, not source

                      +----------------+
                      |  shared-types  |  hub: most projects import it
                      +----------------+
                        |      |      |   emits .d.ts (composite)
             +----------+      |      +----------+
             v                 v                 v
      +--------------+  +--------------+  +--------------+
      |   lib-data   |  |    lib-ui    |  |   lib-auth   |
      +--------------+  +--------------+  +--------------+
             |                 |                 |
             +--------+--------+--------+--------+
                      v                 v
               +--------------+  +--------------+
               |   app-web    |  |   app-api    |
               +--------------+  +--------------+

 Per project: inputs + options match .tsbuildinfo? outputs exist?
              no reference emitted newer .d.ts?  --yes-->  SKIP (no check)
                        | no
                        v
              rebuild, compare new .d.ts signature with stored signature
                |                                   |
         same: body / private edit         changed: exported API edit
                v                                   v
         dependents stay valid             dependents re-checked
         (not re-checked)                  (hub change = most of graph)

tsc --build processes the reference graph leaf-first, skips projects whose .tsbuildinfo state is current, and re-checks dependents only when a project's declaration signature changes, so an API change in a hub library like shared-types invalidates most of the graph.

Mapping the Nx project graph onto TypeScript references

Each Nx project gets its own composite TypeScript project. The composite flag makes tsc emit declaration files and a .tsbuildinfo file, and it requires every source file to be matched by include or files. That requirement gives each project a clear boundary. The edges in the Nx project graph become references entries: if orders-api imports from billing-core, the orders-api config references the billing-core config. tsc -b then builds billing-core first and type-checks orders-api against its emitted .d.ts files, not its source.

Splitting lib, spec, and solution configs

A single tsconfig per project mixes two sets of settings that should stay separate. tsconfig.lib.json includes src and excludes test files. tsconfig.spec.json includes the tests, adds the test runner's global types, and references the project's own lib config. The project's tsconfig.json becomes a solution-style file. It has an empty files array and references only the lib and spec configs.

This split catches real errors. Source code cannot use describe or expect by accident, because those globals exist only in the spec config. Production code also cannot import a test fixture, because fixtures are outside the lib config's include.

Keeping both graphs in sync

You now have two dependency graphs: the one Nx infers from imports and the one written in references. If they drift, things break in two ways.

  • Missing reference: Builds can run in the wrong order, or the import resolves through a path alias and quietly bypasses build mode.
  • Leftover reference: A reference that no longer matches an import triggers rebuilds that nothing needs.

Don't maintain these graphs by hand. Use Nx's sync tooling to generate references from the project graph, or add a CI check that fails when the two graphs disagree.

// libs/orders-api/tsconfig.json: solution-style, holds no files of its own
{
  "files": [],
  "references": [
    { "path": "./tsconfig.lib.json" },
    { "path": "./tsconfig.spec.json" }
  ]
}

// libs/orders-api/tsconfig.lib.json: production source only
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "../../dist/out-tsc/orders-api/lib",
    "types": ["node"]                 // no test globals, so `describe` is an error here
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/test-fixtures/**"],
  // Mirrors the Nx graph edge orders-api -> billing-core; checked against its .d.ts
  "references": [{ "path": "../billing-core/tsconfig.lib.json" }]
}

// libs/orders-api/tsconfig.spec.json: tests, with the test runner's globals
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "../../dist/out-tsc/orders-api/spec",
    "types": ["jest", "node"]
  },
  "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/test-fixtures/**/*.ts"],
  // Tests consume the project's own source through its lib build
  "references": [{ "path": "./tsconfig.lib.json" }]
}

The three tsconfig files for the orders-api project: a references-only solution config, a composite lib config that references billing-core to mirror the Nx dependency edge, and a spec config that adds test globals and references the project's own lib config.

Implementing an nx typecheck target with caching that actually hits

Give each project its own typecheck target that runs tsc --build against that project's tsconfig. For libraries, set emitDeclarationOnly with declaration and composite enabled. Downstream projects only need .d.ts files to type-check, so emitting JavaScript here duplicates work your bundler already does. Applications that nothing references can run tsc --noEmit instead, but they still depend on their libraries' declarations being built.

Point declarationDir (or outDir) and tsBuildInfoFile at a per-project folder under a shared ignored root such as dist/ or out-tsc/, and add that root to .gitignore. Committed .tsbuildinfo files go stale on every branch switch and produce merge conflicts that carry no meaning.

Declare that folder in the target's outputs and set dependsOn to ^typecheck. The two cache layers then work at different granularities:

  • Nx skips a project entirely when its inputs hash matches, and affected detection skips projects the change cannot reach.
  • tsc handles what remains. Inside a project that did run, .tsbuildinfo lets it re-check only the changed files and their dependents.

On CI, cache the Nx cache directory and let Nx restore outputs. Do not cache out-tsc/ separately with its own key.

The two layers can disagree. A .tsbuildinfo restored without its matching declarations tells tsc the project is up to date, and downstream projects then fail with "cannot find module" errors or check against stale types. Restore declarations and build info together as one declared output, never piecemeal.

// libs/ui/project.json (Nx parses project.json as JSONC, so comments are allowed)
// Assumes libs/ui/tsconfig.lib.json sets:
//   "composite": true, "declaration": true, "emitDeclarationOnly": true,
//   "declarationDir": "../../out-tsc/libs/ui",
//   "tsBuildInfoFile": "../../out-tsc/libs/ui/tsconfig.lib.tsbuildinfo"
// and that out-tsc/ is listed in .gitignore.
{
  "name": "ui",
  "root": "libs/ui",
  "sourceRoot": "libs/ui/src",
  "projectType": "library",
  "targets": {
    "typecheck": {
      "executor": "nx:run-commands",
      // Build mode reuses .tsbuildinfo, so only changed files are re-checked
      "options": {
        "command": "tsc --build libs/ui/tsconfig.lib.json"
      },
      // Dependencies' .d.ts files must exist before this project is checked
      "dependsOn": ["^typecheck"],
      // Nx skips the run entirely when this hash matches a cached entry
      "cache": true,
      // One output folder holds both the .d.ts files and the .tsbuildinfo,
      // so Nx always restores them together and they cannot drift apart
      "outputs": ["{workspaceRoot}/out-tsc/libs/ui"]
    }
  }
}

A library's typecheck target runs tsc in build mode after its dependencies and declares a single output folder, so Nx restores the declarations and the .tsbuildinfo file together as one unit.

Migrating an existing workspace step by step

Migrate from the bottom of the dependency graph up. Start with leaf libraries that import nothing else in the workspace. Set composite: true on each one and get a clean build. Then move to the libraries that depend only on finished leaves, add their references entries, and repeat until you reach the applications. Each layer builds on projects that already compile in build mode, so every failure belongs to the project you just changed. Once the whole graph is converted, turn on Nx's module boundary lint rules. Then remove the path aliases that let code bypass declared dependencies.

Cleanup that composite forces

composite requires every source file to be matched by include or files. Loose globs that pulled in sibling folders, scripts or test fixtures will now fail. Untyped JavaScript that path aliases resolved silently needs either declaration files or allowJs with emitted declarations. Ambient globals leaked from another project's types or a stray .d.ts must be declared explicitly where they are used. Circular dependencies are the hardest case. Path aliases tolerated them, but references reject cycles outright. You have to break each cycle by extracting the shared code into a new library.

Measuring and diagnosing

Record typecheck wall time on your own CI before and after migrating. Do not assume a speedup, because cache behaviour and graph shape vary too much between workspaces.

If a build is slow or wrong, run tsc --build --verbose. It reports which projects tsc considers out of date and why. Add --listFiles to catch projects pulling in sources they should read as declarations. For a single project that is still slow, --extendedDiagnostics breaks down file counts, memory and time per phase.

When to avoid project references and what the alternatives are

Those flags tell you where time goes. Sometimes the answer is that references are not the fix.

References pay off when work is skipped: a project's .tsbuildinfo and declarations are current, so tsc -b does not check it again. Three situations remove that benefit:

  • Small workspaces. If a single tsc --noEmit finishes in seconds, maintaining references arrays and composite settings costs more than it saves.
  • Cold CI with no preserved cache. Without restored .tsbuildinfo files or remote cache hits, every run rebuilds everything and also pays for emitting declarations.
  • Hub-shaped graphs. When most projects depend on one shared library that changes constantly, nearly every edit invalidates the whole graph.
Approach Type checks Incremental reuse Main cost
Path aliases, single tsc --noEmit Whole workspace in one program Limited to one program Memory and time grow with workspace size
Per-project noEmit, no references Each project, re-reading dependency sources Nx cache per project Shared code is checked repeatedly
esbuild/swc executors Nothing Fast transpile only Type errors go unnoticed unless checked separately

The esbuild and swc executors remove type information without checking it. Typecheck then becomes its own CI step, and it must be a required check on merge. If it is not, broken types reach main without anything failing.

References also change how the editor behaves. Go-to-definition can open a generated .d.ts file instead of the source, unless declarationMap is enabled. The language server also reads declarations from disk, so editing a dependency without rebuilding it produces phantom errors that disappear after a rebuild or a server restart.

Impact on development cost and engineering time

Moving to project references shifts cost from every run to upfront setup and ongoing maintenance. Whether that pays off depends on how often runs can reuse earlier work.

Ongoing cost Corresponding saving
references arrays must match the project graph Unchanged projects are skipped on warm runs
An extra tsconfig per project, plus composite constraints Affected CI type-checks only what a change reaches
Two cache layers (Nx outputs, .tsbuildinfo) that can disagree A cached result from either layer means no work
Cold builds may be slower because declarations are emitted Declaration output is reused by dependents

Illustrative estimate (assumed numbers, not a benchmark)

Expected run time = T_cold × Σ (share of changes of type i × fraction of graph re-checked for type i).

Assumed inputs:

  • A full alias-based check takes 10 min.
  • A cold build with references takes 11 min, because of declaration emit.
  • 70% of changes touch leaf apps, and these re-check 10% of the graph.
  • 30% of changes touch shared libraries, and these re-check 60% of the graph.

Expected fraction = 0.7 × 0.1 + 0.3 × 0.6 = 0.25. The expected run is 11 × 0.25 ≈ 2.75 min, against 10 min before.

Graph shape drives the result. If most changes touch a widely imported utility library, the fraction approaches 1 and the cold-build penalty dominates.

The investment pays back when

  • Most changes land in leaf or mid-graph projects rather than in a few core libraries.
  • Remote or persistent caching keeps CI runs warm.
  • References are generated or validated automatically instead of edited by hand.
  • Type checking is a measurable share of CI or local wait time.
  • The team will investigate cache misses instead of disabling caching.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to typescript project references nx, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.