TypeScript Circular Dependencies: Breaking Import Cycles
Key Takeaway
Our TypeScript modules had circular dependencies (A imports B, B imports A), causing "undefined" runtime errors and unpredictable initialization order. Restructuring imports with dependency inversion and barrel exports eliminated all circular dependency errors.
The Problem
// canvas-service.ts
import { AnnotationManager } from './annotation-manager';
export class CanvasService {
private annotations: AnnotationManager;
drawAnnotations() {
this.annotations.getAll(); // undefined at runtime!
}
}
// annotation-manager.ts
import { CanvasService } from './canvas-service'; // Circular!
export class AnnotationManager {
private canvas: CanvasService;
render() {
this.canvas.drawAnnotations();
}
}
Issues: "undefined is not a function", initialization errors, build warnings, confusing stack traces.
The Solution
Strategy 1: Dependency Inversion
// types/interfaces.ts
export interface ICanvasRenderer {
drawAnnotations(): void;
}
export interface IAnnotationProvider {
getAll(): Annotation[];
}
// canvas-service.ts
import { IAnnotationProvider } from './types/interfaces';
export class CanvasService implements ICanvasRenderer {
constructor(private annotations: IAnnotationProvider) {}
drawAnnotations() {
const all = this.annotations.getAll();
// Draw annotations
}
}
// annotation-manager.ts
import { ICanvasRenderer } from './types/interfaces';
export class AnnotationManager implements IAnnotationProvider {
constructor(private canvas: ICanvasRenderer) {}
getAll(): Annotation[] {
return this.annotations;
}
render() {
this.canvas.drawAnnotations();
}
}
// app.ts - Wire up dependencies
const annotationManager = new AnnotationManager(canvasService);
const canvasService = new CanvasService(annotationManager);
Strategy 2: Move Shared Types
// types.ts - Shared types only
export interface Annotation {
id: string;
type: string;
}
export interface CanvasConfig {
width: number;
height: number;
}
// canvas-service.ts
import { Annotation, CanvasConfig } from './types'; // No circular dep
// annotation-manager.ts
import { Annotation } from './types'; // No circular dep
Strategy 3: Barrel Exports
// services/index.ts
export { CanvasService } from './canvas-service';
export { AnnotationManager } from './annotation-manager';
export * from './types';
// Other files import from barrel
import { CanvasService, AnnotationManager } from './services';
Implementation Details
Detection with madge
# Install madge
npm install -D madge
# Check for circular dependencies
npx madge --circular --extensions ts,tsx src/
# Visualize dependency graph
npx madge --image graph.svg src/
ESLint Plugin
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 1 }]
}
}
Webpack Plugin
// webpack.config.js
const CircularDependencyPlugin = require('circular-dependency-plugin');
module.exports = {
plugins: [
new CircularDependencyPlugin({
exclude: /node_modules/,
failOnError: true,
cwd: process.cwd()
})
]
};
Impact
| Metric | Before | After | |--------|--------|-------| | Circular dependencies | 23 | 0 | | Runtime undefined errors | 15/week | 0 | | Build warnings | 45 | 0 | | Module initialization issues | 8/week | 0 |
Lessons Learned
- Interfaces Break Cycles: Use dependency inversion principle
- Shared Types Separate: Extract common types to dedicated file
- Detect Early: Use madge/ESLint to catch cycles in CI
- Barrel Exports Help: Central export point reduces direct imports
- Design Better: Circular deps often indicate poor architecture