TypeScript
·8 min read·↗

How to Detect and Fix Circular Dependencies in TypeScript

Main cover illustration for article: How to Detect and Fix Circular Dependencies in TypeScript
Summarize with AI:

When building scalable TypeScript applications in Angular, NestJS, or Next.js, we often focus on strong types, interfaces, and modular architecture. But there is a silent bug that catches even senior engineers off guard: Circular Dependencies.

You write your code, TypeScript compiles without any errors, the tests seem to pass, but when you run your application in production, you are suddenly hit with:

TypeError: Cannot read properties of undefined (reading 'DarkTheme')
ReferenceError: Cannot access 'ButtonConfig' before initialization

Why does this happen? And more importantly, how can we detect and eliminate these loops?

In this guide, I will share how circular references happen, why JavaScript module evaluation breaks them at runtime, how to use automated detection tools like Madge, and the exact refactoring patterns—including import type—to keep your dependency graph clean and unidirectional.


The Problem: Why Circular Dependencies Crash at Runtime

To understand the danger of a circular dependency, we need to understand how the JavaScript runtime evaluates ES modules (import/export).

⚠️ The Circular Loop:
theme.ts (needs ButtonConfig) ➔ imports button.ts
button.ts (needs Theme) ➔ imports theme.ts (Cycle!)

If Module B immediately tries to access a class, constant, or function from A during startup (for example, initializing a default configuration), that value is still undefined.

Note: TypeScript validates types at compile-time, but ES module execution happens at runtime. If you want a refresher on how TypeScript types behave at runtime, check out my guide on how to check types in TypeScript.

Let's look at a concrete real-world scenario where this happens.


Scenario: The Theme and Button Configuration

Imagine we are building a UI component system with custom themes and buttons.

We decide to create theme.ts with color definitions and theme presets:

// theme.ts
export type Theme = {
  color: string;
  fontFormat: string;
};
 
export const DarkTheme: Theme = {
  color: 'black',
  fontFormat: 'italic',
};
 
export const LightTheme: Theme = {
  color: 'white',
  fontFormat: 'bold',
};

Next, to keep our code modular, we create button.ts to manage button components and their configuration:

// button.ts
import { Theme } from "./theme";
 
export type ButtonConfig = {
  label: string;
  theme: Theme;
};

So far, button.ts imports from theme.ts. The dependency direction is clean and unidirectional.

Now, someone on the team decides to add a default preset configuration to theme.ts that includes a pre-configured list of buttons.

Let's see what happens when we update theme.ts:

// theme.ts (Updated)
import { ButtonConfig } from "./button"; // ⚠️ CIRCULAR IMPORT CREATED!
 
export type Theme = {
  color: string;
  fontFormat: string;
};
 
export const DarkTheme: Theme = {
  color: 'black',
  fontFormat: 'italic',
};
 
export const defaultThemeConfig = {
  type: 'dark',
  buttons: [
    { theme: DarkTheme, label: 'Accept' },
    { theme: DarkTheme, label: 'Cancel' },
  ] as ButtonConfig[],
};

Notice what just happened:

  1. theme.ts now imports ButtonConfig from button.ts.
  2. button.ts imports Theme from theme.ts.

Both files depend on each other. When your bundler (Vite, Webpack, esbuild, or Node.js) loads button.ts first, DarkTheme will evaluate to undefined, crashing your application at runtime.

Now let's see how we can automatically detect these dependency loops before they reach production.


2. Detecting Circular Dependencies with Madge and ESLint

In small projects, you might spot circular references manually. But in enterprise codebases with hundreds of files, you need automated tooling.

Using Madge CLI

Madge is an exceptional open-source tool that analyzes module dependencies and visualizes circular references.

Run this command in your project directory:

npx madge --circular --extensions ts ./src

If a circular dependency exists, Madge outputs the exact cycle:

✖ Found 1 circular dependency!
 
1) theme.ts > button.ts > theme.ts

You can also generate an interactive visual graph with:

npx madge --image dependency-graph.svg --extensions ts ./src

Preventing Cycles in CI/CD with ESLint

To prevent teammates or future commits from introducing circular imports, configure the import/no-cycle rule in .eslintrc.json:

{
  "plugins": ["import"],
  "rules": {
    "import/no-cycle": ["error", { "maxDepth": 5 }]
  }
}

Now that we know how to identify the problem, let's explore the three best architectural techniques to fix it.


3. Pattern 1: Extracting Shared Contracts (The Intermediate Module)

The cleanest way to fix a circular dependency is to extract the shared types and definitions into an independent, lower-level module.

We introduce theme-config.ts to hold the combined configuration:

// 1. theme.ts (Independent base definitions)
export type Theme = {
  color: string;
  fontFormat: string;
};
 
export const DarkTheme: Theme = {
  color: 'black',
  fontFormat: 'italic',
};
 
export const LightTheme: Theme = {
  color: 'white',
  fontFormat: 'bold',
};
// 2. button.ts (Depends only on theme.ts)
import { Theme } from "./theme";
 
export type ButtonConfig = {
  label: string;
  theme: Theme;
};
// 3. theme-config.ts (Brings theme and button together)
import { DarkTheme } from "./theme";
import { ButtonConfig } from "./button";
 
export type ThemeConfig = {
  type: 'dark' | 'light';
  buttons: Array<ButtonConfig>;
};
 
export const defaultThemeConfig: ThemeConfig = {
  type: 'dark',
  buttons: [
    { theme: DarkTheme, label: 'Accept' },
    { theme: DarkTheme, label: 'Cancel' },
  ],
};

The New Clean Dependency Graph

theme.ts (Independent Base) ───► button.ts
       │                             │
       ▼                             ▼
       └────────► theme-config.ts ◄──┘

The cycle is completely broken! Each module has a single, well-defined responsibility.

Now let's examine another powerful TypeScript feature for type-only cycles: import type.


4. Pattern 2: Using Type-Only Imports (import type)

Often, circular dependencies happen only because one file needs a TypeScript interface or type for type-checking, without needing any runtime JavaScript code.

Starting in TypeScript 3.8, you can use Type-Only Imports:

// button.ts
import type { Theme } from "./theme"; // 👈 Type-only import!
 
export type ButtonConfig = {
  label: string;
  theme: Theme;
};

Why import type Eliminates the Runtime Cycle

When TypeScript compiles this file to JavaScript, import type is completely removed from the output bundle (Type Erasure):

// Compiled JavaScript output for button.ts
// Notice: The import of "./theme" does NOT exist in the JS bundle!

If your cycle only involves TypeScript types or interfaces, import type resolves the issue immediately without needing to create new files.

Tip: If you want to avoid any while keeping your types clean, see my article on why to avoid using 'any' in TypeScript.

Now let's look at the third most common cause of circular dependencies: Barrel files.


5. Pattern 3: Fixing Barrel File (index.ts) Import Loops

A very frequent trap in modern frontend projects is the Barrel File (index.ts re-exports).

Imagine you have a components/ folder with an index.ts:

// components/index.ts
export * from './Button';
export * from './Card';
export * from './Modal';

If Card.tsx imports Button via the barrel file:

// components/Card.tsx
import { Button } from './index'; // ❌ RISKY: Imports from own parent barrel!

This creates an indirect circular dependency: index.ts loads Card.tsx, which asks index.ts for Button, before index.ts has finished exporting Button.

The Fix: Direct Internal Imports

Inside the same feature folder or module, always use direct relative imports:

// components/Card.tsx
import { Button } from './Button'; // ✅ SAFE: Direct internal import

Reserve barrel files (index.ts) strictly for external consumers outside that directory.


Summary Decision Matrix

ScenarioRoot CauseBest Solution
Type-Only CycleTwo files import each other's type or interfaceUse import type { ... } from './file'
Value / Runtime CycleTwo files import each other's classes or const objectsExtract shared logic into a 3rd module (config.ts, models.ts)
Barrel File CycleInternal file imports a sibling via index.tsChange to direct relative import (./Button)
Complex Domain LogicTightly coupled domain servicesApply Dependency Inversion (interface abstraction)

Conclusion & Next Steps

Circular dependencies in TypeScript can cause silent, baffling runtime bugs that pass compile-time type checking.

By following these four principles:

  1. Use import type whenever you only need type definitions.
  2. Structure your modules in a unidirectional dependency flow.
  3. Avoid importing sibling components from internal index.ts barrel files.
  4. Enforce automated checks with Madge and the ESLint import/no-cycle rule.

You can ensure your TypeScript architecture stays clean, modular, and completely free of runtime circular traps.

If you want to explore more practical TypeScript patterns, check out these related guides on my blog:

Part of the TypeScript Series

Mastering the language of the web. From basic types to advanced generics and patterns.

View Entire Series

Frequently Asked Questions

What is a circular dependency in TypeScript and why is it dangerous?

A circular dependency occurs when Module A imports Module B, and Module B directly or indirectly imports Module A. While the TypeScript compiler may compile without syntax errors, at runtime Node.js or the browser must decide which module to evaluate first. The module evaluated first will receive an uninitialized, 'undefined' reference to the other module, causing runtime crashes like 'TypeError: Cannot read properties of undefined' or 'ReferenceError: Cannot access X before initialization'.

How does TypeScript 'import type' help avoid circular dependency runtime errors?

When a circular reference only exists for static type definitions (interfaces, types), using 'import type { MyType } from ./module' tells the TypeScript compiler that this import should be completely stripped during compilation (type erasure). Because no JavaScript import statement is emitted in the compiled output, the runtime dependency cycle is completely eliminated.

What tools can automatically detect circular dependencies in a TypeScript project?

You can use CLI tools like Madge ('npx madge --circular --extensions ts ./') and DPDM ('npx dpdm --circular ./**.ts') to scan your codebase and visualize dependency trees. For continuous prevention in CI/CD, configure the ESLint rule 'import/no-cycle' from eslint-plugin-import.

What architectural patterns help resolve circular dependencies in large codebases?

The most effective patterns include: 1) Extracting shared contracts into independent types/models files, 2) Applying Dependency Inversion by depending on abstractions rather than concrete implementations, and 3) Refactoring index.ts barrel files so internal modules do not import from their own parent barrel.

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.