Why hand-maintained TypeScript project references drift

TypeScript project references let you split a monorepo into separately compiled units. Each package's tsconfig.json lists the packages it depends on in a references array. Each referenced package sets composite: true and emits declaration files. tsc -b reads that graph, builds packages in dependency order, and skips any whose inputs haven't changed. The graph is only correct if it matches the imports in your source code, and TypeScript does not keep the two in step for you.

That gap is where drift starts. Every new cross-package import needs a matching references entry in the importing package's tsconfig. Removing an import should remove the entry, but almost nobody does that.

Consider an illustrative repo with three packages: api, domain and utils. api references domain, and domain has no references. A developer adds an import from utils inside domain and forgets the tsconfig change. On their machine the build passes, because utils was compiled earlier and its declaration output is already on disk. In a clean CI checkout, tsc -b has no reason to build utils before domain, so the import resolves to missing or outdated declarations. The resulting errors point at files the developer never touched. The opposite case is quieter. When an import is removed and its reference stays behind, builds do extra work, and the dependency graph shows coupling that no longer exists.

Backend and platform engineers feel this most. They own the build in monorepos with dozens of packages, where many teams edit imports but few check the tsconfig files.

// packages/domain/tsconfig.json (tsconfig files accept comments)
// domain/src/order.ts now contains:
//   import { formatMoney } from "@acme/utils";
{
  "compilerOptions": {
    // Required for any project that others reference.
    // composite also turns on declaration output.
    "composite": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"],
  "references": [
    // This is the entry that gets forgotten. Without it, `tsc -b`
    // does not know domain depends on utils, so it may not build
    // utils first. A local build still passes if utils/dist already
    // exists, but a clean CI checkout fails.
    { "path": "../utils" }
  ]
}

// packages/utils/tsconfig.json must also set "composite": true.
// packages/api/tsconfig.json keeps { "path": "../domain" }.
// If domain later stops importing utils, the entry above should be
// deleted too. Otherwise the build does extra work and the graph
// keeps a dependency that no longer exists.

The domain package's tsconfig.json after a new import from utils, with the references entry that must be added by hand to keep tsc -b build order correct.

What Nx sync generators are and how they work

A generator in Nx is code that writes or edits files in the workspace. Most generators are one-shot: you run one to scaffold a library, it creates files, and it never runs again. A sync generator is different. Its job is to make a set of derived files match the current project graph, and it is meant to run again and again. Running it twice on an unchanged workspace should produce no changes. A diff therefore means something has drifted.

The @nx/js TypeScript sync generator applies this to project references. Nx already builds a project graph by analyzing the import statements in your source. The generator reads that graph. For each project, it writes references entries that point to the projects it depends on. It also removes entries for dependencies that no longer exist. As a result, tsc -b walks the same edges Nx uses for task ordering and caching, and the two tools no longer disagree about what depends on what.

The flow:

Source imports → Nx project graph → TypeScript sync generator → references in tsconfig files → tsc -b build order

Data flows one way. You edit imports, and the references follow. Nobody hand-edits the references array.

Before relying on this, check which files your installed plugin version actually updates. Workspaces differ in layout. Some put references only in each project's tsconfig.json. Others split them into tsconfig.lib.json and spec configs. The root tsconfig.json may or may not be included. Run the generator once on a clean branch and read the diff.

 +----------------------+        +--------------------------+
 | Source imports       |        | Nx project graph         |
 | import { x }         |------->| app -> ui                |
 |   from '@acme/ui'    |        | ui  -> utils             |
 +----------------------+        +--------------------------+
                                              |
                                              v
                                 +--------------------------+
                                 | @nx/js TypeScript        |
                                 | sync generator           |
                                 |  - add missing refs      |
                                 |  - remove stale refs     |
                                 |  - no diff when in sync  |
                                 +--------------------------+
                                              |
                                              v
 +----------------------+        +--------------------------+
 | tsc -b               |        | "references" in tsconfig |
 | builds projects in   |<-------| files (which ones: check |
 | the same dep order   |        | your plugin version)     |
 +----------------------+        +--------------------------+

 One direction only: edit imports -> references follow.
 Nobody hand-edits the references array.

The TypeScript sync generator turns the Nx project graph into tsconfig references, so tsc -b follows the same dependency edges Nx uses.

Task vs. global sync generators: choosing a registration mode

Nx gives you two places to register a sync generator. The choice determines when the generator runs and which files it may touch.

Task sync generators attach to a specific target through its syncGenerators option, for example "syncGenerators": ["@nx/js:typescript-sync"] on a build or typecheck target, either in project.json or in targetDefaults. Before Nx runs that task, it runs the listed generators. This ties the derived state to the task that depends on it. If tsc --build needs correct references, the references are checked or updated immediately before tsc runs, and only when it runs.

Global sync generators are listed in nx.json under sync.globalGenerators, for example "sync": { "globalGenerators": ["my-plugin:sync-codeowners"] }. They are not tied to any task. They run when you invoke nx sync explicitly, and nx sync:check covers them as well.

Aspect Task sync generator Global sync generator
Registered in Target's syncGenerators option nx.json → sync.globalGenerators
Trigger Before the owning task runs, and on nx sync Only on nx sync / nx sync:check
Scope Files the task needs to be correct Workspace-wide derived files
Typical use TypeScript project references, generated path config CODEOWNERS, docs indexes, root-level manifests

Use the task mode when a stale file would make a task fail or produce wrong output. Use the global mode when the derived file matters to people or tools outside the task graph.

These configuration keys have changed between Nx releases. Check the documentation for the Nx version installed in your workspace before copying them.

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "syncGenerators": ["@nx/js:typescript-sync"]
    },
    "typecheck": {
      "syncGenerators": ["@nx/js:typescript-sync"]
    }
  },
  "sync": {
    "globalGenerators": ["my-plugin:sync-codeowners"]
  }
}

An nx.json that registers @nx/js:typescript-sync as a task sync generator on build and typecheck via targetDefaults, and a placeholder CODEOWNERS generator as a global sync generator that runs only on nx sync and nx sync:check.

Implementing it: prerequisites, local workflow and the CI check

Prerequisites

The generator can only write references that TypeScript will accept and that Nx can infer. Every referenced project needs composite: true in its tsconfig, which also implies declaration output, so tsc --build can consume its .d.ts files instead of re-checking its sources. Output directories should follow one convention across the workspace, such as each project's own dist or out-tsc folder. Otherwise tsc -b emits into colliding or unpredictable locations. The project graph must also reflect real dependencies. The generator reads the edges Nx detects from imports and package.json dependencies. A dependency Nx cannot see, such as a dynamic require with a computed path or an undeclared alias, produces no reference.

Local workflow

When you run a task that has a sync generator registered, Nx first checks whether any derived files are stale. Depending on sync.applyChanges in nx.json, it either asks you before writing or applies the changes directly. To update everything at once without running a task, use nx sync.

A typical change looks like this: a new import from libs/billing in apps/api adds a { "path": "../../libs/billing" } entry to the references array in apps/api/tsconfig.json. Commit that edit in the same commit as the import. Reviewers can then see the reference change alongside the code that caused it.

The CI check

Run nx sync:check early in the pipeline, alongside lint and format checks. It exits non-zero when files are out of sync, so a stale reference fails the PR. CI should check and never apply. If CI rewrites files, the build tests a configuration that nobody committed, and the next developer's checkout drifts again.

#!/usr/bin/env bash
# ci-checks.sh: fast, read-only checks that run before build and test.
# Any non-zero exit fails the pipeline and the PR.
set -euo pipefail

# Install exactly what is in the lockfile so the project graph matches
# what developers committed.
npm ci

# 1. Fail if any sync generator would change files, for example a
#    missing or stale entry in a tsconfig.json "references" array.
#    This only reports. It never writes, so CI builds the configuration
#    that is actually in the commit.
npx nx sync:check

# 2. Formatting check, also read-only.
npx nx format:check

# 3. Lint. If sync:check passed, the references are already correct
#    and later tsc --build steps see the committed graph.
npx nx run-many -t lint

# Do NOT run 'npx nx sync' here. Rewriting files in CI hides drift:
# the build would pass on a config nobody committed, and the next
# checkout would be stale again. Fix it locally instead:
#   npx nx sync
#   git add apps/api/tsconfig.json
#   git commit --amend --no-edit   # keep the reference with its import

A CI check script that runs nx sync:check next to the format and lint checks, so it fails on stale TypeScript project references instead of rewriting them.

Migrating an existing repo and rolling out incrementally

Migration checklist

  1. Start from a clean branch. Commit or stash everything so the only diff in the sync PR comes from the generator. Git is your snapshot of the current tsconfigs, and it makes the next step reviewable.
  2. Run nx sync once across the workspace. It rewrites references in each project's tsconfig.json (and the root solution config) to match the project graph.
  3. Review the diff as an audit of coupling, not as noise.
    • An added reference means code already imports another project that nobody declared. The build only worked because of path aliases or build order.
    • A removed reference usually means a dependency that was refactored away and never cleaned up.
    • If a removal looks wrong, check whether the import is dynamic or generated. Those cases need an explicit dependency in project configuration. Do not hand-edit the reference back in.
  4. Merge the sync PR, then enable nx sync:check in CI. If you turn the check on before the baseline lands, every open PR fails at once.

Rolling out gradually

In a large repo, you may not want every task to trigger every sync generator on day one. The sync block in nx.json accepts disabledTaskSyncGenerators, which lists generators, such as @nx/js:typescript-sync, that should not run as part of task execution. You can disable a generator there while teams clean up their projects, then remove the entry once the workspace is consistent.

When results disagree with the code

If nx sync reports changes that don't match the imports you can see, the cause is usually a stale daemon or cached project graph. Run nx reset to clear Nx's local state, then rerun the sync before you treat the output as real.

Writing a custom sync generator for other derived files

Project references are one case of a broader pattern: any file whose correct contents can be derived from the project graph is a candidate for a sync generator. Common examples are paths aliases in a root tsconfig, a CODEOWNERS-style file built from project tags or metadata, and per-project config such as a lint or build setting that depends on which projects a project consumes.

The contract

A sync generator has three responsibilities:

  1. Compute the desired state from the graph. Load the project graph through the devkit (createProjectGraphAsync in @nx/devkit). Do not scan the filesystem yourself, because the graph already reflects Nx's view of projects and dependencies.
  2. Write through the Nx Tree. Read the current file with tree.read, build the expected contents, and call tree.write only if the two differ. Never write with fs directly. nx sync:check runs your generator against the virtual tree and inspects the pending changes, so writes that bypass the tree are both invisible to the check and destructive.
  3. Return an out-of-sync message. When you change something, return an object with an outOfSyncMessage that names the file and the reason, for example "CODEOWNERS is missing entries for 2 projects." This message is the only explanation a developer sees when CI fails.

A minimal skeleton

The generator is an async default export that receives the Tree. It loads the graph and derives a sorted, deterministic output. It compares that output with the existing file, writes and returns a message on mismatch, and returns nothing otherwise.

Keep the output stable. Sort keys and format the file consistently. Any nondeterminism makes sync:check fail intermittently.

Before you build on this, check the exact types, return shape and registration keys against your installed Nx version.

Trade-offs, when to avoid it, alternatives and impact on engineering time

Sync generators have real costs. Generated tsconfig edits appear in diffs nobody typed, so the team needs a convention: run nx sync before committing and treat reference changes as part of the change that caused them. References are only as accurate as the project graph. Dynamic imports, string-built require calls, deep relative imports across project boundaries and undeclared dependencies all leave gaps that tsc -b reports later as confusing errors. Unusual layouts, such as several tsconfigs per project or hand-tuned reference lists, may be overwritten or only partly managed. Formatting and comments can also churn. Auto-applying sync in CI hides drift instead of failing on it, so CI should only check.

Small repos, and teams that don't need incremental tsc -b, gain little from project references in the first place.

Approach Maintenance Incremental builds Failure mode
Hand-maintained references High, recurring Yes Silent drift
Path aliases, single tsc Low No Slow full type-checks
Custom scripts Medium (you own the script) Yes Script rot
Nx sync generators Low Yes Graph gaps

Sync generators reduce the cost of maintaining and debugging references. They do not reduce the cost built into the model itself: composite constraints, declaration emit and disciplined output directories.

Illustrative estimate (assumed numbers, not a benchmark): Hours saved per month = drift incidents × hours per incident. Assume 4 incidents a month at 1.5 hours each: 4 × 1.5 = 6 hours a month. Then subtract the time spent on rollout and on reviewing generated diffs.

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 nx sync generators, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.