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 checks. 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
- 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
- One-shot: You run it manually or in CI — it doesn't prevent new cycles from being introduced
- No enforcement: It reports cycles but doesn't block PRs
- Configuration needed: Path aliases, module resolution, and TypeScript config require manual setup
- 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
- Slow on large projects: The rule traces every import chain, which can significantly slow down linting on codebases with thousands of files
- maxDepth trade-off: A high
maxDepthcatches more cycles but is slower. A low value misses indirect cycles - No architecture-level view: It works file-by-file — you don't get a big-picture view of your dependency graph
- 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
- Interactive dependency graph: Explore your module dependencies visually — zoom, click, filter
- PR bot with Pass/Fail: Every PR is checked automatically. No CI configuration needed
- Architecture rules in YAML: Define custom rules beyond just "no cycles" — layer enforcement, coupling limits, module boundaries
- Health score: Track your architecture health over time, catch drift before it becomes a problem
- No code storage: ReposLens analyzes in memory only. 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. 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
- 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