Skip to content

Repo guidance: Avoid circular dependencies

Circular dependencies (A imports B, B imports A) between files are legal in TypeScript and ES modules but introduce non-determinism at bundle time, which can result in undefined exports or order-sensitive bugs. Some bundlers have known edge cases where cycles degrade tree-shaking or cause execution order problems.

Basic example of a cycle:

ts
// a.ts
import { bFn } from './b'
export const aValue = 1
export function aFn() {
  return bFn() + aValue
}

// b.ts
// This import reads before aFn executes fully. The constant `aValue` may be
// initialized, but more complex patterns (class inheritance, mutated singletons)
// become fragile.
import { aValue } from './a'
export function bFn() {
  return aValue
}

Why cycles hurt

  • Initialization ordering: Partially evaluated modules can expose incomplete objects.
  • Performance: Extra parsing work to establish strongly connected components.
  • Tool fragility: Edge-case bugs surface more often inside cycles.

Common sources

  • Barrel files (index.ts) that re-export siblings and are imported back by those siblings.
  • Overly large utility modules re-exported widely.
  • Cross-layer leakage (e.g. UI layer importing data layer which imports UI types).

Detection

Use dependency graph tooling (e.g. madge) in CI to fail on new cycles.

Example madge usage:

bash
madge src --circular

Refactoring strategies

  • Extract shared types/constants into a new leaf module with no imports.
  • Invert dependencies via callbacks or interfaces.
  • Split large modules into smaller, directional layers.
  • Replace barrel-based intra-package imports with direct relative imports.