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
undefinedat 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.jsonflag - Empty result but cycles exist: Check
--webpack-configfor 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
undefinedin 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
sideEffectsin 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)