nest-cycle
v0.2.3
Published
Nest-aware static circular dependency detector — finds the exact module cycle before runtime.
Maintainers
Readme
nest-cycle
Nest-aware static circular-dependency detector — finds the exact
@Modulecycle before runtime and names it plainly.
Why
NestJS circular dependencies are painful. When one occurs, Nest throws something like:
Nest cannot create the UsersModule instance.
The module at index [1] of the UsersModule "imports" array is undefined.
- A circular dependency between modules. Use forwardRef() to avoid it.
Scope [AppModule -> AuthModule]index [1] is undefined sends you — and any AI assistant — hallucinating about
missing imports and undefined variables. Nest never plainly says which two
modules form the loop.
madge finds import cycles, but NestJS cycles live at the DI level, and
forwardRef() hides them from the import graph entirely. nest-cycle reads the
module graph the way Nest resolves it and draws the loop for you:
✖ 1 cycle will crash bootstrap
Module cycles
Tangle (2 modules): AuthModule, UsersModule
🔴 unguarded — this crashes NestFactory.create
AuthModule src/auth/auth.module.ts:6
→ UsersModule src/users/users.module.ts:9
→ AuthModule
↳ fix: break one edge — wrap the lighter import in forwardRef(() => X)Every node is a clickable file:line. It separates the cycles that actually
crash bootstrap (no forwardRef) from those Nest resolves via forwardRef,
so the one that broke your server isn't buried under the ones that didn't.
Install
npm i -D nest-cycle
# or run without installing:
npx nest-cycleUsage
nest-cycle # scan whole project (auto-detects ./tsconfig.json)
nest-cycle TestModule # only cycles through TestModule (paste from Nest's error)
nest-cycle --project apps/api/tsconfig.app.json
nest-cycle --json # machine-readable output
nest-cycle --helpAdd a CI gate:
{ "scripts": { "cycle": "nest-cycle" } }Exit codes: 0 clean · 1 cycle found · 2 usage error. Drops straight
into CI — a cycle fails the build.
Options
| Option | Description |
| --- | --- |
| [ModuleName] | Only show cycles passing through this module or provider. Case-insensitive, partial match. |
| -p, --project <path> | Path to a tsconfig.json or a project directory. Default: auto-detect ./tsconfig.json, else scan ./ for *.ts. |
| -c, --config <path> | Allowlist file. Default: ./nest-cycle.json if present. |
| --strict | Fail on forwardRef-guarded cycles too, not just crash-causing ones. |
| --json | Machine-readable output for tooling/editors. |
| --runtime | Reserved (not yet — static-only). |
| -v, --version | Print version. |
| -h, --help | Show help. |
@Optional() dependencies are ignored — an optional circular dep doesn't crash
(Nest injects undefined), so it's not reported. Set NO_COLOR=1 to disable colour.
What it handles
Module-level (@Module({ imports })):
forwardRef(() => X)— unwrapped and flagged as the masked edge.- Dynamic modules —
ConfigModule.forRoot(),TypeOrmModule.forFeature([...]).
Provider-level (@Injectable) — since v0.2.0:
- Constructor injection by class type —
constructor(private a: AService). @Inject(ClassRef)/@Inject(forwardRef(() => X))on constructor params or properties — masked edge flagged.- Injection by string/symbol token (
@Inject('TOKEN')) is counted as unresolved, never guessed. (useFactory/useClassinject arrays: v0.3.0.)
Anything it can't read statically (spread / conditional / variable / token) is counted and reported — it never claims you're clean when it just couldn't see.
Under the hood: Tarjan (strongly-connected components) groups each tangle, then a capped, shortest-first DFS enumerates the distinct loops inside it. So a 5-module knot reads as one problem, with the concrete loops listed under it.
Allowlist
Knowingly keep a forwardRef cycle? Mute it so CI stays green. Drop a
nest-cycle.json in your project root:
{
"allow": [
["AuthModule", "UsersModule"],
["AService", "BService"]
]
}A loop is muted when every module/provider in it belongs to one allow-set.
Muted loops don't affect the exit code; they're reported as
🔇 N known cycles muted.
JSON output
{
"willCrash": true,
"unguardedLoops": 1,
"guardedLoops": 0,
"mutedTotal": 0,
"modules": {
"scanned": 12,
"unresolved": 0,
"cyclic": true,
"muted": 0,
"groups": [
{
"members": ["AuthModule", "UsersModule"],
"unguardedLoops": 1,
"guardedLoops": 0,
"cycles": [
{
"guarded": false,
"steps": [
{ "name": "AuthModule", "filePath": "src/auth/auth.module.ts", "line": 6, "forwardRefInto": false },
{ "name": "UsersModule", "filePath": "src/users/users.module.ts", "line": 9, "forwardRefInto": false }
]
}
],
"truncated": 0
}
]
},
"providers": { "scanned": 8, "unresolved": 0, "cyclic": false, "muted": 0, "groups": [] }
}guarded is true when a loop has ≥1 forwardRef edge (Nest resolves it).
forwardRefInto marks the step whose outgoing edge is a forwardRef.
willCrash is true when any unguarded loop exists — the exit-1 signal.
Roadmap
- v0.2 ✅ — provider-level cycles (class-type injection); allowlist;
fix-hints; guarded/unguarded classification;
@Optional()handling. - v0.3.0 —
useFactory/useClass/useExistinginject arrays; custom-token resolution viaprovide:; Nx/monorepo multi-project scan. - Later —
--runtimemode that reads the live Nest DI container.
Development
npm install
npm run build # tsc -> dist
npm test # assert-based fixtures, no frameworkLicense
MIT © RbMo7
