TypeScript Circular Dependencies: Breaking Import Cycles

·frontend-explore

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

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
MetricBeforeAfter
Circular dependencies230
Runtime undefined errors15/week0
Build warnings450
Module initialization issues8/week0

Lessons Learned

  1. Interfaces Break Cycles: Use dependency inversion principle
  2. Shared Types Separate: Extract common types to dedicated file
  3. Detect Early: Use madge/ESLint to catch cycles in CI
  4. Barrel Exports Help: Central export point reduces direct imports
  5. Design Better: Circular deps often indicate poor architecture