How to Detect Circular Dependencies in Your TypeScript Project (and Fix Them)
Circular dependencies are one of the most frustrating problems in TypeScript projects. They cause mysterious build failures, undefined imports at runtime, and bugs that are nearly impossible to trace. The worst part? They creep in silently, one innocent import at a time, until your entire module graph is tangled.
If you've ever seen TypeError: Cannot read properties of undefined in a file that clearly exports a value, or watched your bundler enter an infinite loop, chances are you're dealing with a circular dependency.
This guide covers three practical methods to detect circular dependencies in TypeScript projects, from manual CLI tools to fully automated PR quality gates. We'll also cover common patterns to fix them once you've found them.
Why Circular Dependencies Are Dangerous in TypeScript
TypeScript compiles to JavaScript, and JavaScript module loading is sequential. When module A imports module B, and module B imports module A, the runtime has to resolve this cycle, and it does so by returning a partially initialized module. This leads to:
1. Undefined exports at runtime
// userService.ts
import { sendEmail } from './emailService';
export function createUser(name: string, email: string) {
const user = { id: crypto.randomUUID(), name, email };
sendEmail(user.email, 'Welcome!'); // works fine
return user;
}
export function getUserById(id: string) {
// ... lookup logic
return { id, name: 'John', email: 'john@example.com' };
}
// emailService.ts
import { getUserById } from './userService'; // circular!
export function sendEmail(to: string, subject: string) {
console.log(`Sending "${subject}" to ${to}`);
}
export function sendUserReport(userId: string) {
const user = getUserById(userId); // might be undefined!
sendEmail(user.email, 'Your report');
}
This compiles without errors. TypeScript doesn't warn you. But at runtime, depending on which file is loaded first, getUserById might be undefined when emailService.ts tries to use it.
2. Build and bundle failures
Tools like Webpack, Vite, and esbuild can sometimes handle simple cycles, but complex circular dependency chains cause: Infinite loops during tree-shaking, incorrect code splitting, Hot Module Replacement (HMR) breaking silently, and test runners failing with cryptic errors.
3. Architectural decay
Circular dependencies are a code smell that signals tight coupling. If module A depends on module B and vice versa, they can't be tested, deployed, or understood independently. Over time, this creates a "big ball of mud" where every change risks breaking something elsewhere.
Method 1: Detect with madge (CLI)
madge is a command-line tool that creates dependency graphs and finds circular dependencies. It's the quickest way to scan your project.
Installation and usage
# Install globally or as a dev dependency
npm install -g madge
# or
npm install --save-dev madge
# Find circular dependencies in a TypeScript project
madge --circular --extensions ts,tsx src/
# With tsconfig path aliases
madge --circular --extensions ts,tsx --ts-config tsconfig.json src/
# Generate a visual graph (requires Graphviz)
madge --circular --image graph.svg src/
Example output
Circular dependencies found!
1) src/services/userService.ts > src/services/emailService.ts
2) src/models/Order.ts > src/models/Product.ts > src/models/Order.ts
3) src/utils/auth.ts > src/middleware/session.ts > src/utils/auth.ts
Adding to package.json scripts
{
"scripts": {
"check:circular": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src/"
}
}
Limitations of madge
It is a one-shot tool, you run it manually or in CI, and it doesn't prevent new cycles from being introduced. There is no enforcement, meaning it reports cycles but doesn't block PRs. Configuration is needed since path aliases, module resolution, and TypeScript config require manual setup. And there is no visualization, the text output can be hard to parse for large projects (the SVG graph helps but isn't interactive).
Method 2: ESLint plugin import/no-cycle
The eslint-plugin-import provides a rule called no-cycle that catches circular dependencies as you code.
Installation
npm install --save-dev eslint-plugin-import @typescript-eslint/parser
ESLint configuration
For flat config (eslint.config.mjs):
import importPlugin from 'eslint-plugin-import';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
import: importPlugin,
},
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
},
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
rules: {
'import/no-cycle': ['error', { maxDepth: 5 }],
},
},
];
For legacy .eslintrc.json:
{
"parser": "@typescript-eslint/parser",
"plugins": ["import"],
"settings": {
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5 }]
}
}
How it works
The rule traces import chains up to maxDepth levels deep. When it detects a cycle, it reports an error:
src/services/emailService.ts
1:1 error Dependency cycle detected import/no-cycle
Limitations
The rule can be slow on large projects since it traces every import chain, which can significantly slow down linting on codebases with thousands of files. There is also a maxDepth trade-off: a high maxDepth catches more cycles but is slower, while a low value misses indirect cycles. It provides no architecture-level view since it works file-by-file, you don't get a big-picture view of your dependency graph. And it is essentially CI only, it catches cycles during linting, but doesn't provide a visual dashboard or historical tracking.
Method 3: Automated Detection on Every PR with ReposLens
The first two methods are useful but reactive, you run them manually or in CI, and they tell you about cycles that already exist. ReposLens takes a different approach: it detects circular dependencies automatically on every pull request and blocks PRs that introduce new ones.
How it works
- Install the ReposLens GitHub App on your repository (takes 60 seconds)
- ReposLens analyzes your codebase and generates an interactive dependency graph
- On every PR, it runs architecture checks including circular dependency detection
- If a PR introduces a new cycle, it posts a comment and blocks the merge
What you get
You get an interactive dependency graph that lets you explore your module dependencies visually with zoom, click, and filter capabilities. A PR bot with Pass/Fail checks every PR automatically, with no CI configuration needed. You can define architecture rules in YAML that go beyond just "no cycles", including layer enforcement, coupling limits, and module boundaries. A health score lets you track your architecture health over time and catch drift before it becomes a problem. And there is no code storage, ReposLens analyzes in memory only, so your source code is never stored (GDPR compliant).
Compared to madge and ESLint
| Feature | madge | ESLint no-cycle | ReposLens | |---------|-------|-----------------|-----------| | Detects cycles | Yes | Yes | Yes | | Blocks PRs | No | Via CI | Native | | Interactive graph | No | No | Yes | | Setup time | 5 min | 10 min | 60 seconds | | Custom architecture rules | No | No | Yes | | Health score & tracking | No | No | Yes | | Performance impact | None | Slows linting | None |
How to Fix Circular Dependencies
Once you've found cycles, here are the most common patterns to break them.
Pattern 1: Extract shared types into a separate module
The most common cause of cycles is two modules sharing types:
// BEFORE (circular)
// user.ts imports from order.ts, order.ts imports from user.ts
// AFTER (fixed)
// types.ts, shared types, no imports from user or order
export interface UserRef {
id: string;
name: string;
}
export interface OrderRef {
id: string;
total: number;
}
// user.ts, imports from types.ts only
import { OrderRef } from './types';
// order.ts, imports from types.ts only
import { UserRef } from './types';
Pattern 2: Dependency inversion (interface extraction)
When module A needs to call module B and vice versa, introduce an interface:
// BEFORE (circular)
// notificationService.ts imports userService.ts
// userService.ts imports notificationService.ts
// AFTER (fixed)
// Define interface in a separate file
export interface UserLookup {
getUserEmail(userId: string): Promise<string>;
}
// notificationService.ts depends on the interface, not the implementation
export class NotificationService {
constructor(private userLookup: UserLookup) {}
async notify(userId: string, message: string) {
const email = await this.userLookup.getUserEmail(userId);
// send notification
}
}
// userService.ts implements the interface but doesn't import NotificationService
export class UserService implements UserLookup {
async getUserEmail(userId: string): Promise<string> {
// lookup logic
return 'user@example.com';
}
}
Pattern 3: Move shared logic to a utils/helpers module
Sometimes the cycle exists because two modules share a utility function:
// BEFORE: auth.ts imports from session.ts, session.ts imports from auth.ts
// Both need a `hashToken` function
// AFTER: extract hashToken to crypto-utils.ts
// crypto-utils.ts (no circular imports)
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
// auth.ts imports from crypto-utils.ts only
// session.ts imports from crypto-utils.ts only
Pattern 4: Avoid barrel file re-exports
Barrel files (index.ts) that re-export everything from a directory are a common source of indirect cycles:
// BEFORE: index.ts creates indirect cycles
// src/models/index.ts
export * from './User';
export * from './Order';
export * from './Product';
// If User imports from Order and Order imports from this barrel → cycle
// AFTER: import directly from specific files
import { User } from './models/User';
import { Order } from './models/Order';
Conclusion
Circular dependencies are a structural problem, they signal that your module boundaries are leaking and architecture is drifting. The three methods covered here form a spectrum:
madge is great for a quick audit of an existing codebase. ESLint import/no-cycle catches cycles during development, but slows down linting. And ReposLens prevents cycles from ever reaching your main branch, with zero CI configuration.
The best approach depends on your team size and workflow. For solo developers and small teams, ReposLens offers the fastest path to continuous architecture enforcement, install in 60 seconds, detect cycles on every PR, and track your architecture health over time.
See also: How to Detect Circular Dependencies in Any Codebase | ReposLens vs dep-cruiser
Related Articles
Microservices vs Monolith: How to Actually See Your Architecture
Monolith or microservices? The real question is: can you see what you actually have? Learn how to visualize, compare, and decide with confidence.
Continue readingMonorepo Dependency Management: Visualize Before It's Too Late
Learn how to manage internal dependencies in a monorepo. Discover common pitfalls like circular deps and god packages, and tools to visualize your architecture.
Continue readingHow to Set Up Automated PR Quality Gates on GitHub
A practical guide to setting up automated quality gates on GitHub pull requests, from linting and tests to architecture checks that prevent structural degradation.
Continue reading