← Back

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

| 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

  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