Detect TypeScript Import Cycles in 12 Minutes with Graph Analysis

Find and fix circular dependencies in TypeScript projects using dependency graph algorithms and automated detection tools.

Problem: Mysterious Build Failures and Runtime Errors

Your TypeScript build succeeds but you get undefined at runtime, or webpack throws "Module has no exports" errors. The culprit? Import cycles hiding in your dependency graph.

You'll learn:

  • How to visualize your project's dependency graph
  • Automated detection of circular imports
  • Breaking cycles without refactoring everything
  • Prevention strategies for large codebases

Time: 12 min | Level: Intermediate


Why This Happens

TypeScript processes imports top-to-bottom. When file A imports B, and B imports A, one of them receives a partially-initialized module—leading to undefined properties or missing exports.

Common symptoms:

  • Functions or classes are undefined at runtime
  • "Cannot read property of undefined" in constructors
  • Webpack warnings about circular dependencies
  • Inconsistent behavior between dev and production builds

Real example:

// user.service.ts
import { AuthService } from './auth.service';
export class UserService {
  constructor(private auth: AuthService) {}
}

// auth.service.ts
import { UserService } from './user.service'; // Cycle!
export class AuthService {
  constructor(private users: UserService) {}
}

Solution

Step 1: Install Detection Tools

# Madge analyzes dependencies and detects cycles
npm install -D madge

# Optional: dependency-cruiser for advanced rules
npm install -D dependency-cruiser

Why madge? It builds a dependency graph from your imports and identifies strongly-connected components (cycles).


Step 2: Scan Your Project

# Find all circular dependencies
npx madge --circular --extensions ts,tsx src/

# Generate visual graph (requires Graphviz)
npx madge --circular --extensions ts,tsx --image graph.svg src/

Expected output:

✖ Found 3 circular dependencies!

1) auth.service.ts > user.service.ts > auth.service.ts
2) order.model.ts > customer.model.ts > order.model.ts
3) api/index.ts > api/routes.ts > api/handlers.ts > api/index.ts

If it fails:

  • "Cannot find module": Add --tsconfig tsconfig.json flag
  • Empty result but cycles exist: Check --webpack-config for alias resolution

Step 3: Analyze the Graph Structure

// Critical: Identify cycle types

// Type 1: Direct cycle (easy fix)
// A → B → A

// Type 2: Transitive cycle (needs extraction)
// A → B → C → A

// Type 3: Barrel export cycle (refactor barrels)
// index.ts → module-a.ts → index.ts

Use the visual graph to find the shortest path in each cycle—that's your break point.


Step 4: Break the Cycle

Strategy A: Extract Shared Interface

// Before: Direct cycle
// user.service.ts
import { AuthService } from './auth.service';

// After: Extract interface
// interfaces/auth.interface.ts
export interface IAuthService {
  validateToken(token: string): boolean;
}

// user.service.ts
import { IAuthService } from './interfaces/auth.interface';
export class UserService {
  constructor(private auth: IAuthService) {} // No cycle!
}

// auth.service.ts - no import of user.service needed
import { IAuthService } from './interfaces/auth.interface';
export class AuthService implements IAuthService {
  validateToken(token: string): boolean { ... }
}

Strategy B: Dependency Injection

// Before: Constructor imports create cycle
// After: Inject via composition

// app.ts (orchestration layer)
const authService = new AuthService();
const userService = new UserService();

// Wire dependencies after instantiation
authService.setUserService(userService);
userService.setAuthService(authService);

Strategy C: Lazy Imports

// Use dynamic imports to defer loading
export class UserService {
  async getAuthService() {
    const { AuthService } = await import('./auth.service');
    return new AuthService();
  }
}

When to use each:

  • Interface extraction: Business logic cycles (90% of cases)
  • Dependency injection: Framework-level cycles (NestJS, Angular)
  • Lazy imports: Rare, large modules (hurts performance)

Step 5: Prevent Future Cycles

Add madge to your CI pipeline:

// package.json
{
  "scripts": {
    "check:cycles": "madge --circular --extensions ts,tsx src/",
    "pre-commit": "npm run check:cycles"
  }
}

GitHub Actions example:

# .github/workflows/check-dependencies.yml
name: Dependency Check
on: [pull_request]
jobs:
  cycles:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run check:cycles

Step 6: Advanced Detection with dependency-cruiser

For complex projects, enforce architectural rules:

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: 'no-circular',
      severity: 'error',
      from: {},
      to: { circular: true }
    },
    {
      name: 'no-cross-layer',
      severity: 'error',
      from: { path: '^src/domain' },
      to: { path: '^src/infrastructure' }
    }
  ]
};
npx depcruise --config .dependency-cruiser.js src/

This catches:

  • Circular dependencies
  • Layer violations (domain importing infrastructure)
  • Orphan modules

Verification

# 1. Check for cycles
npm run check:cycles

# 2. Build should succeed
npm run build

# 3. Runtime test
npm test

You should see:

  • ✅ No circular dependencies found
  • ✅ All modules export correctly
  • ✅ No undefined in runtime tests

Smoke test:

// Quick runtime verification
import { UserService } from './user.service';
const service = new UserService();
console.assert(service.auth !== undefined, 'Auth should be defined');

What You Learned

  • Import cycles cause partial module initialization—one module gets an incomplete export
  • Graph algorithms (strongly-connected components) efficiently detect cycles in large codebases
  • Interface extraction solves 90% of cycles without major refactoring
  • CI integration prevents cycles from creeping back in

Limitations:

  • Some cycles are architectural—tools detect them but fixing requires redesign
  • Dynamic imports hurt tree-shaking and bundle size
  • Interface extraction adds boilerplate (worth it for maintainability)

When NOT to worry:

  • Type-only imports (import type) don't create runtime cycles
  • Cycles in test files (if they don't affect production)

Graph Theory Background (Optional)

Import cycles are strongly-connected components in a directed graph:

Graph representation:
Nodes = TypeScript files
Edges = import statements

Cycle detection algorithm:
1. Build adjacency list from imports
2. Run Tarjan's SCC algorithm (O(V+E) complexity)
3. Any SCC with >1 node = cycle

Madge uses this internally with the `dependency-graph` npm package.

Why this matters: Understanding the graph structure helps you identify the minimum cut—the fewest imports to remove to break all cycles.


Common Cycle Patterns

Pattern 1: Barrel Export Cycle

// ❌ BAD: index.ts re-exports everything
// index.ts
export * from './user.service';
export * from './auth.service';

// user.service.ts
import { AuthService } from './index'; // Imports from barrel - cycle!

Fix: Import directly from files, not barrels:

// ✅ GOOD
import { AuthService } from './auth.service';

Pattern 2: Enum/Constant Cycle

// constants.ts
import { UserRole } from './user.model';
export const DEFAULT_ROLE = UserRole.Guest;

// user.model.ts
import { DEFAULT_ROLE } from './constants'; // Cycle!

Fix: Move shared enums to a dedicated file with no dependencies.

Pattern 3: Event Handler Cycle

// event-bus.ts
import { UserCreatedHandler } from './handlers';

// handlers/user-created.ts
import { EventBus } from '../event-bus'; // Cycle!

Fix: Use dependency injection or event-driven architecture (pub/sub).


Real-World Example: Breaking a 4-File Cycle

Detected cycle:

order.service.ts → customer.service.ts → 
invoice.service.ts → payment.service.ts → order.service.ts

Analysis: Each service imports the next for business logic validation.

Solution: Extract shared domain interfaces:

// domain/interfaces.ts (no dependencies)
export interface IOrder { id: string; total: number; }
export interface ICustomer { id: string; creditLimit: number; }
export interface IInvoice { id: string; paid: boolean; }
export interface IPayment { id: string; amount: number; }

// Each service imports only interfaces, not implementations
// order.service.ts
import { ICustomer, IInvoice } from '../domain/interfaces';

export class OrderService {
  validateOrder(customer: ICustomer): boolean {
    // Uses interface - no import of customer.service.ts
  }
}

Result: Cycle broken. Each service depends on interfaces, not concrete classes.


Debugging Tips

If madge misses cycles:

# Check with TypeScript compiler
tsc --noEmit --traceResolution | grep -i cycle

# Or use ESLint
npm install -D eslint-plugin-import
// .eslintrc.json
{
  "plugins": ["import"],
  "rules": {
    "import/no-cycle": ["error", { "maxDepth": 10 }]
  }
}

If cycles only appear in production:

  • Check webpack/vite bundling order
  • Verify sideEffects in package.json
  • Test with NODE_ENV=production npm run build

Tested on TypeScript 5.6, madge 8.x, Node.js 22.x Graph visualization requires Graphviz: brew install graphviz (macOS) or apt-get install graphviz (Ubuntu)