A circular dependency in JavaScript usually shows up as one of three symptoms. A function that should return a number returns undefined. A class is not a constructor even though the import looks fine. Or a Vitest run fails on the first import in a file that has not changed in months.
The cause is almost always the same shape. Two modules import each other directly, or three or four modules form a longer ring. The runtime starts loading one of them, hits the cycle, and returns whatever exports happen to exist at that moment. Often that is nothing.
This guide is the JavaScript and TypeScript specific companion to our understanding circular dependencies post. It covers what the runtime actually does, the tools that detect cycles in a JS or TS codebase, and the four patterns that fix them without rewriting half the project.
Why JavaScript cycles are weirder than most
In Go, the compiler refuses to build. In Rust, the module system rules them out. In JavaScript, the runtime tries to be helpful and ends up handing you a partially initialised object instead of an error.
Both module systems behave differently.
CommonJS (require). When a module is being required and is required again before its first run completes, Node returns the current state of module.exports, which may be {}. Any property reads on that object return undefined. The first symptom is usually TypeError: X is not a function deep in a call chain.
ECMAScript Modules (import). ESM resolves the import graph statically before any code runs, so you do not get an empty {} back. Instead, the live binding is in the temporal dead zone until the source module finishes initialising. If the cycle reaches the binding too early you get ReferenceError: Cannot access 'X' before initialization. The Node.js ESM docs and the MDN modules guide both cover this in detail.
Mixed CJS and ESM. A package that compiles to CJS depends on a package that ships ESM, or vice versa. The interop layer multiplies the resolution edge cases. Cycles across the boundary are the worst version of this problem.
The takeaway: a JavaScript cycle is rarely a clean error. It is usually a partial object, an undefined function, or a value that is fine in production but undefined in tests because the test loader walks the graph in a different order.
The four ways cycles appear in real codebases
Worth recognising the shape, because the fix depends on it.
Two file mutual import. userService.ts imports a helper from auth.ts. auth.ts imports a type or constant from userService.ts. Easiest to spot, easiest to fix.
Three or more file ring. a -> b -> c -> a. Often grows out of refactors that move a function from one home to another without checking what already imported the destination.
Barrel file cycle. A folder has an index.ts re-exporting every file in the directory. Any file inside that folder that imports from the same index.ts (or imports from another folder whose barrel re-exports back) creates a cycle. Barrels are a common silent source of cycles in TypeScript monorepos.
Type-only cycle. Two modules import types from each other. The compiled output may be fine because TypeScript erases the types, but the source still triggers import/no-cycle lint warnings, and any value re-used alongside the type drags the cycle into runtime.
Detect them with one command
The JavaScript and TypeScript ecosystem has three battle-tested tools for this. Pick one and add it to CI.
madge. Fast, simple, the most common choice. Run npx madge --circular --extensions ts,tsx,js,jsx src/. Outputs a list of cycles and exits non-zero if any exist. The flag --circular --warning is useful when adopting it on an existing codebase that already has cycles.
dpdm. Better at handling TypeScript path aliases and import type distinctions out of the box. Run npx dpdm --no-tree --no-warning --exit-code circular:1 src/. If you want a tool that ignores type-only cycles, dpdm is the easiest to configure that way.
dependency-cruiser. The most configurable. Lets you write rules like "no cycles" alongside other architecture rules ("the components folder must not import from the routes folder"). Heavier setup, more capable.
For most JavaScript and TypeScript repos, madge in CI is enough. Set the build to fail on any new cycle. Keep a tracked list of existing ones (a CI baseline) and shrink it over time.
# Add to CI before the build step
npx madge --circular --extensions ts,tsx --exclude '\.test\.' src/
Two minutes of setup. Catches cycles the moment they land.
Fix one: extract the shared contract
The most common cycle is two modules that import each other because they share types or constants.
// Before: cycle
// userService.ts
import type { Session } from "./auth";
export class UserService { /* ... */ }
// auth.ts
import { UserService } from "./userService";
export type Session = { user: UserService };
The fix is a third module both sides import.
// types.ts
export type Session = { userId: string };
// userService.ts
import type { Session } from "./types";
export class UserService { /* ... */ }
// auth.ts
import type { Session } from "./types";
The pattern is "pull the shared contract up". It works for types, constants and small pure functions. It usually adds one file. It almost always reduces total coupling.
Fix two: switch to type-only imports
If the cycle is purely about types, TypeScript's import type syntax (or the explicit import { type Foo } form) instructs the compiler to erase the import at build time. The runtime never sees it, so the cycle disappears.
// Before: runtime cycle
import { UserService } from "./userService";
// After: erased at build time
import type { UserService } from "./userService";
The catch: import type only works when you are using the symbol as a type. The moment you reference it as a value (new UserService()), you are back to the runtime cycle.
ESLint's @typescript-eslint/consistent-type-imports rule auto-fixes these once enabled. Worth turning on in any TypeScript codebase. The TypeScript handbook on modules covers the full set of import-type semantics.
Fix three: dependency injection at the boundary
When two modules genuinely need each other's behaviour, the import is not the right tool. Pass the behaviour in at the boundary.
// Before: cycle through behaviour
// orderService.ts
import { sendEmail } from "./notifier";
export const createOrder = (data: OrderData) => {
// ...
sendEmail(data.email, "Order received");
};
// notifier.ts
import { OrderService } from "./orderService"; // for retry logic
Replace the import with a parameter.
// orderService.ts
type Notifier = { sendEmail: (to: string, body: string) => void };
export const createOrder = (data: OrderData, notifier: Notifier) => {
// ...
notifier.sendEmail(data.email, "Order received");
};
// notifier.ts can now reference orderService freely
This is closer to how the system would have been designed if the two responsibilities had been separated from day one. The first cycle in a codebase is usually the moment the team realises a boundary is wrong.
Fix four: kill the barrel
Barrel files (index.ts re-exports) are the single largest source of accidental cycles in TypeScript monorepos. They look harmless. They make autocomplete cleaner. They ruin tree-shaking and create cycles whenever an internal file imports through the barrel.
Two cures.
Stop creating new barrels. New folders import directly from the source file. import { UserService } from "./users/userService" not import { UserService } from "./users". One extra path segment, no cycle, better tree-shaking. Tools like esbuild and the webpack tree-shaking guide both document why direct imports help bundlers.
Inside a folder, never import from your own barrel. A file in users/ should never import { ... } from ".". It should import from sibling files directly. The barrel is for outside consumers only. Configure the lint rule import/no-internal-modules or write a custom dependency-cruiser rule to enforce this.
This single change typically removes 60 to 80 percent of cycles in a TypeScript monorepo. It is the highest-leverage fix on this list.
What about CommonJS?
If you are still on CommonJS (older Node services, certain older libraries), the same fixes apply with one extra technique.
Lazy require. Move the require from the top of the file to inside the function that needs it. By the time the function runs, the cycle has resolved.
// Before
const userService = require("./userService"); // empty at first load
function createUser(data) {
return userService.create(data);
}
// After
function createUser(data) {
const userService = require("./userService"); // resolved by call time
return userService.create(data);
}
This is a workaround, not a fix. It hides the cycle rather than removing it. Use it as a last resort when restructuring is not viable, then fix the structure later.
A practical sequence for an existing codebase
When inheriting a codebase with dozens of cycles, do not try to fix them all at once. The cycles interact, and fixing one can require fixing another first.
- Add madge to CI in baseline mode (allow existing cycles, fail only on new ones).
- Generate the current cycle list. Most repos have between zero and 30.
- Pick the cycles inside barrels first. Removing internal barrel imports knocks out a large fraction.
- Pick the type-only cycles next.
import typeplus the consistent-type-imports lint rule resolves these in a single pass. - The remaining cycles are real architecture issues. Apply the shared-contract or DI patterns.
- Once the count is low, flip CI from baseline to strict: any cycle fails the build.
A team that takes this seriously can usually reach zero cycles in two or three weeks of part-time work, even on codebases that have accumulated them for years. The tooling and patterns covered in how to improve your codebase architecture help with the structural decisions along the way.
What changes once cycles are gone
Three concrete things, all measurable.
Tree-shaking gets better. Bundlers can remove unused code that previously had to be kept because it was inside a cycle. Production bundle sizes drop, sometimes by ten to twenty percent on cycle-heavy apps. We touch on the wider set of bundle wins in performance anti-patterns in modern JavaScript.
Tests get faster and simpler. Mocking one module no longer drags the whole cycle into the mock. Test setup files shrink. Vitest and Jest cold starts speed up.
Refactors stop scaring people. Moving a function between modules no longer risks breaking unrelated code. The cost of change drops, which is the whole point of paying down structural debt.
The bottom line
Circular dependencies in JavaScript are a structural problem with a small set of fixes. Detecting them takes one CI command. Fixing them takes one of four patterns: shared contract, type-only import, dependency injection or kill the barrel.
The teams that act early keep their cycle count near zero indefinitely. The ones that ignore it spend a sprint fixing them every two years and learn the same lessons each time.
Pick madge or dpdm. Add it to CI. Fail the build on new cycles. Pay down the existing list one pattern at a time. The codebase becomes easier to reason about within a month.