What sync generators are and the drift problem they solve

A TypeScript monorepo stores the same dependency facts in several places. The Nx project graph already knows that orders-api depends on shared-auth. That fact is also written by hand into orders-api/tsconfig.json as a project reference, often into a path mapping in the root tsconfig, sometimes into per-project tool config such as lint or test settings, and indirectly into ownership files like CODEOWNERS.

Each copy goes stale on its own schedule. Suppose an engineer adds an import from shared-auth but forgets the tsconfig reference. Editor tooling may still resolve the import, while tsc --build either fails or builds in the wrong order. Removing a dependency causes the reverse problem: stale references stay behind and slow builds down. When several teams change dependencies every week, nobody owns these files, and review rarely catches the gaps.

A sync generator removes the hand-written copies. It reads the current workspace state, mainly the project graph, computes what each derived file should contain, and returns the file changes. It can also return an out-of-sync message explaining what drifted. You run it with nx sync or tie it to tasks so it runs before them.

Normal generator Sync generator
Input User-supplied options Current workspace state
Purpose Scaffold new code Reconcile existing files
When it runs On demand, once Repeatedly, before tasks or in CI
Expected output on rerun New files each time No changes if already in sync

Sync generators are most useful to platform and backend engineers who maintain medium-to-large TypeScript monorepos where many teams change the dependency graph.

How sync generators work: task-level vs global registration

Nx offers two places to register a sync generator, and the choice determines when the generator runs.

Task-level registration

A task-level sync generator is attached to a specific target, such as build or typecheck. Before Nx runs that task, it runs the generator and checks whether the workspace is out of step. Use this when the drift only matters to that task. For example, stale tsconfig references break tsc --build, so the generator belongs on the targets that invoke the compiler. Because it runs only when that task is about to run, its cost stays tied to work you were already doing.

Global registration

Global sync generators are listed in nx.json and cover workspace-wide concerns that no single target owns. Examples include a root-level path map, a shared CODEOWNERS-style file, or editor configuration derived from the project list. Choose global registration when the output describes the workspace as a whole rather than the inputs to one task.

Nx's TypeScript sync is the standard example. It reads the project graph, finds each project's dependencies, and rewrites the references arrays in the matching tsconfig files so they mirror those edges. When you add or remove a dependency, the references follow.

Applying vs. prompting

A setting in nx.json controls whether Nx applies sync changes automatically or asks first. Locally, prompting lets you see and review the edits before they land. In CI, run nx sync:check so the pipeline fails when files are out of sync instead of rewriting files nobody will commit. The registration shape and option names have changed between releases, so confirm them in the documentation for your installed Nx version.

# Excerpt of nx.json showing both registration styles.
# Confirm key names against the docs for your installed Nx version.
cat > nx.json.example <<'EOF'
{
  "targetDefaults": {
    "typecheck": {
      "syncGenerators": ["@nx/js:typescript-sync"]
    },
    "build": {
      "syncGenerators": ["@nx/js:typescript-sync"]
    }
  },
  "sync": {
    "globalGenerators": ["@myorg/workspace-plugin:codeowners-sync"]
  }
}
EOF
# Task-level (targetDefaults): the TypeScript sync runs before build and
# typecheck, because stale tsconfig references only break tsc there.
# Global (sync.globalGenerators): workspace-wide output no single target owns.
# The generator name above is a placeholder for a local plugin.
# sync.applyChanges is left unset on purpose: when it is unset,
# Nx prompts before writing sync changes before a task runs.

# Locally: apply all pending sync changes explicitly, then review the diff.
npx nx sync
git diff -- '*tsconfig*.json'

# In CI: check only. Fails the job if files are out of sync,
# instead of rewriting files that nobody will commit.
npx nx sync:check

An nx.json excerpt shows the TypeScript sync registered on the build and typecheck targets and a placeholder generator registered globally. The commands show applying sync changes locally with nx sync and running the check-only nx sync:check in CI.

Implementing a custom sync generator step by step

As a working example, take a generator that writes a CODEOWNERS-style file mapping each project root to an owning team, based on the project's tags. The generator receives a Tree, which is the virtual file system Nx uses to stage changes. It then runs in four steps.

1. Read the project graph

Load the graph with createProjectGraphAsync from @nx/devkit. From each project node, take only what you need: the name, the root and the tags. Do not glob the filesystem or parse source files. The graph is already computed and cached, so reading it keeps task-level runs cheap.

2. Compute the expected contents

Build the output from that data alone, and make the result deterministic:

  • Stable ordering. Sort projects by root, and sort owners within each line. Graph iteration order is not guaranteed to stay the same.
  • POSIX paths. Normalize every path to forward slashes. Otherwise a Windows contributor produces a diff on every run.
  • No volatile data. Leave out timestamps, generator versions and absolute paths. Any value that varies between machines or runs makes the file permanently out of sync.

3. Compare with the current file

Read the existing file with tree.read(path, 'utf-8') and compare the strings.

4. Write only when the contents differ

If the strings match, return without touching the tree. An untouched tree is how Nx knows nothing is out of sync.

If they differ, call tree.write and return an outOfSyncMessage that names the file and the cause. For example: "CODEOWNERS is missing entries for 2 projects". A vague "files changed" message forces developers to diff the output themselves to work out what happened.

import { Tree, createProjectGraphAsync, normalizePath } from '@nx/devkit';

const OUTPUT = 'CODEOWNERS';

export default async function codeownersSync(
  tree: Tree
): Promise<{ outOfSyncMessage?: string } | void> {
  // 1. Read the cached project graph; no filesystem globbing.
  const graph = await createProjectGraphAsync();

  // 2. Compute expected contents from name, root and tags only.
  const lines = Object.values(graph.nodes)
    .map((node) => {
      const root = normalizePath(node.data.root); // POSIX separators on every OS
      const owners = (node.data.tags ?? [])
        .filter((tag) => tag.startsWith('team:'))
        .map((tag) => `@example-org/${tag.slice('team:'.length)}`)
        .sort(); // stable owner order within a line
      return { root, owners };
    })
    .filter((entry) => entry.owners.length > 0)
    .sort((a, b) => a.root.localeCompare(b.root, 'en')) // fixed locale: same order on every machine
    .map(({ root, owners }) => `/${root}/ ${owners.join(' ')}`);

  // No timestamps or versions: identical input must give identical output.
  const expected = `# Generated by codeowners-sync. Do not edit.\n${lines.join('\n')}\n`;

  // 3. Compare with the current file in the Tree.
  const current = tree.read(OUTPUT, 'utf-8');

  // 4. Leave the tree untouched when in sync; Nx reads that as up to date.
  if (current === expected) return;

  tree.write(OUTPUT, expected);
  return {
    outOfSyncMessage: current === null
      ? `${OUTPUT} does not exist and must be generated from project tags.`
      : `${OUTPUT} does not match the team:* tags in the project graph.`,
  };
}

A minimal sync generator that builds a CODEOWNERS file from project tags in the project graph, with sorted output and POSIX paths. It writes to the Tree only when the contents differ and returns a specific outOfSyncMessage.

Testing for idempotency and formatter agreement

After the first run against a tree from createTreeWithEmptyWorkspace, read the contents of every file the generator touched and keep them. Run the generator a second time on the same tree and compare those files byte for byte. Also assert that the second run reports nothing to fix: no outOfSyncMessage, and no new entries from tree.listChanges() beyond what the first run already wrote. If the second run rewrites anything, nx sync:check will fail on a clean checkout. The check then fails without any real drift, and developers learn to rerun nx sync without reading the message.

The usual causes are iteration order and environment leakage:

  • Building arrays from Object.keys over the project graph, or from a Set populated in traversal order, without sorting.
  • Absolute paths or timestamps written into output.
  • Line endings that differ by OS.

Sort references by path and normalize everything else.

Formatter agreement is a separate failure. If the generator writes JSON that Prettier reformats, the pre-commit hook and the sync check will each change the file back to their own version. Call formatFiles(tree) at the end of the generator. In the test, pass the output through Prettier with the repo's resolved config and assert that the result is unchanged. Use a real config fixture rather than defaults so the test catches config-specific drift such as trailing commas or print width.

Treat the generator as production code. It needs:

  • A named owner in CODEOWNERS.
  • Tests that run in CI.
  • A review whenever you upgrade Nx, because the devkit and project graph APIs it depends on can change.
import { addProjectConfiguration, type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import * as prettier from 'prettier';
import * as path from 'node:path';
import { tsconfigSyncGenerator } from './generator';

// Real repo-style Prettier config, so print width and trailing commas match CI.
const PRETTIER_FIXTURE = path.join(__dirname, '__fixtures__', '.prettierrc.json');

function touched(tree: Tree): Map<string, string> {
  const files = new Map<string, string>();
  for (const change of tree.listChanges()) {
    if (change.type !== 'DELETE') files.set(change.path, tree.read(change.path, 'utf-8')!);
  }
  return files;
}

describe('tsconfigSyncGenerator', () => {
  let tree: Tree;
  beforeEach(() => {
    tree = createTreeWithEmptyWorkspace();
    // Seed projects in non-alphabetical order to expose unsorted output.
    addProjectConfiguration(tree, 'zeta', { root: 'libs/zeta' });
    addProjectConfiguration(tree, 'alpha', { root: 'libs/alpha' });
  });

  it('is idempotent: a second run changes nothing', async () => {
    await tsconfigSyncGenerator(tree);
    const first = touched(tree);

    const second = await tsconfigSyncGenerator(tree);
    expect(second?.outOfSyncMessage).toBeUndefined();
    // Same set of changed paths, same bytes.
    expect(touched(tree)).toEqual(first);
  });

  it('writes output the repo formatter would leave alone', async () => {
    await tsconfigSyncGenerator(tree);
    for (const [file, content] of touched(tree)) {
      const config = await prettier.resolveConfig(file, { config: PRETTIER_FIXTURE });
      expect(await prettier.format(content, { ...config, filepath: file })).toBe(content);
    }
  });
});

A Jest test that runs a sync generator twice on a test Tree to assert the second run reports no drift and leaves files byte-identical, then checks that every written file is already in the form Prettier produces under the repo's config fixture.

Enforcing sync in CI and adopting it in an existing repo

Locally, developers run nx sync to apply every registered generator's changes. In CI, run nx sync:check instead. It computes the same changes without writing them and exits non-zero if any derived file is stale. This puts drift in the pull request that caused it, where the context is fresh, instead of in someone else's branch a week later.

Adopting it in an existing repo

Do not enable the check first. In a repo that has drifted for years, run nx sync once and commit the result as a single dedicated commit with no other changes. That keeps the large diff reviewable and easy to skip in blame. Tell the team before merging, because open branches will conflict on the touched files. Enable the CI check only after that commit lands.

Some files will stop being hand-editable. For these, split them: the generator owns a base config, and a hand-maintained file extends it. Note the split in a comment at the top of each file so nobody edits the wrong one.

Debugging out-of-sync reports

The check output identifies which generator wants to change which files. Reproduce the failure locally with nx sync, then inspect the diff. If the diff overwrites something written by hand, move that setting into the hand-maintained layer.

A wrong reference usually means the project graph is wrong. Dynamic imports and runtime-only links can hide real dependencies. Confirm the edge with nx graph. If it is missing, declare it, for example with implicitDependencies, rather than patching the generated file.

#!/usr/bin/env bash
set -euo pipefail

# --- One-time adoption, run by one person on a fresh branch from main ---

# Apply every registered sync generator's changes in one pass.
npx nx sync

# Commit only the generated changes. Do not mix in hand edits.
git add -A
git commit -m "chore: apply nx sync across workspace"

# Let 'git blame' skip this commit so history stays readable.
git rev-parse HEAD >> .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: ignore nx sync commit in blame"
# Each developer opts in locally:
#   git config blame.ignoreRevsFile .git-blame-ignore-revs

# --- After the commit lands and the team knows: add this CI step ---

# Computes the same changes without writing them.
# Exits non-zero if any derived file is stale, which fails the PR.
npx nx sync:check

# --- Debugging a failed check locally ---

# Reproduce: apply the changes, then look at what the generator wanted.
npx nx sync
git diff -- '*tsconfig*.json'

# If a reference looks wrong, check the dependency edge in the graph.
# Missing edges (dynamic imports, runtime-only links) belong in
# project.json "implicitDependencies", not in the generated file.
npx nx graph

Shows the one-time adoption sequence (apply nx sync, isolate it in a dedicated commit excluded from blame), the nx sync:check step for CI, and the local commands for reproducing and tracing an out-of-sync report.

When to avoid sync generators and what the alternatives are

A sync generator is only as correct as its inputs. If the config you want to derive depends on something the project graph cannot see, the generator will confidently write wrong output. That category includes runtime plugin loading, environment-specific paths, and files owned by another tool. Skip sync generators in that case, and in three others:

  • Very simple repos. A handful of projects with stable dependencies change too rarely to justify the extra code.
  • Older Nx versions. Workspaces that predate the feature have nothing to register a generator against, and upgrading just for this is rarely worth it.
  • No clear owner. If nobody on the team will maintain the generator, it becomes code nobody understands, and people start bypassing nx sync instead of fixing it.
Approach Correctness Maintenance burden Drift risk
Hand-maintained config plus code review Depends on reviewer attention Low upfront, grows with repo size High; reviewers miss omissions more often than errors
Standalone script plus CI diff check Good if the script reads a reliable source Low; one file, no plugin API Low in CI, but caught late
Lint rules that validate Good for detecting problems; fixes stay manual Moderate Low; fails loudly without rewriting files
Built-in TypeScript sync only Accurate for project references Near zero Low for tsconfig, none covered elsewhere

For many teams, the built-in TypeScript sync handles the common case. If you only need to guarantee that a file matches a rule, a validating lint rule gives you the guarantee without code that writes files.

Impact on development cost and engineering time

The costs arrive early and land on one person or team. The savings are spread thinly across every contributor, so they are easy to underestimate.

Cost or saving When it occurs Who absorbs it
Writing and testing the generator Once Platform team
One-time migration diff Once Reviewers of that PR
Tracking Nx devkit API changes Each major Nx upgrade Platform team
Pre-task sync check Every run of a registered task Everyone, in small amounts
Manual reference updates removed Every project add or move Every contributor
Stale-reference CI failures avoided Whenever someone forgets Author and reviewers
Config churn in reviews shrinks Every structural PR Reviewers

Illustrative estimate (assumed numbers, not a benchmark). Assume these inputs:

  • 10 engineers.
  • Each engineer hand-fixes a stale reference twice a month, at 15 minutes per fix.
  • One broken CI run a month caused by a stale reference, costing 30 minutes.
  • 16 hours to build the generator.
  • 2 hours of maintenance per quarter, which is about 0.67 hours a month.

Use this formula:

break-even months = build hours ÷ (engineers × fixes per month × hours per fix + CI breaks × hours per break − monthly maintenance hours)

With the assumed inputs: 16 ÷ (10 × 2 × 0.25 + 1 × 0.5 − 0.67) = 16 ÷ 4.83 ≈ 3.3 months.

The pre-task sync check is left out of the formula. In a large graph it can matter, so time it on your own repository before relying on the result.

Is this derived file worth automating?

  • It can be computed entirely from the project graph or other committed files.
  • It changes when projects are added, renamed or moved.
  • People demonstrably forget to update it.
  • When it is wrong, the failure shows up late, in CI or at runtime, rather than in the editor.
  • The generator's output is deterministic and stays stable after formatting.
  • A validating lint rule would leave developers doing the fix by hand.

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.