---
title: "How to Detect and Fix Circular Dependencies in TypeScript"
date: "2024-03-15"
slug: "how-to-detect-and-fix-circular-dependencies-in-typescript"
author: "Dany Paredes"
canonical: "https://danywalls.com/how-to-detect-and-fix-circular-dependencies-in-typescript"
description: "Learn why circular dependencies break TypeScript applications at runtime, how to detect them using Madge and ESLint, and how to fix them using type-only imports and clean architectural patterns."
---


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:

```text
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](https://danywalls.com/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:

```typescript
// 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:

```typescript
// 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`:

```typescript
// 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](https://www.npmjs.com/package/madge)** is an exceptional open-source tool that analyzes module dependencies and visualizes circular references.

Run this command in your project directory:

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

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

```text
✖ Found 1 circular dependency!

1) theme.ts > button.ts > theme.ts
```

You can also generate an interactive visual graph with:

```bash
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`:

```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:

```typescript
// 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',
};
```

```typescript
// 2. button.ts (Depends only on theme.ts)
import { Theme } from "./theme";

export type ButtonConfig = {
  label: string;
  theme: Theme;
};
```

```typescript
// 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
```text
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**:

```typescript
// 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**):

```javascript
// 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](https://danywalls.com/why-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`:

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

If `Card.tsx` imports `Button` via the barrel file:

```typescript
// 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**:

```typescript
// 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

| Scenario | Root Cause | Best Solution |
| :--- | :--- | :--- |
| **Type-Only Cycle** | Two files import each other's `type` or `interface` | Use `import type { ... } from './file'` |
| **Value / Runtime Cycle** | Two files import each other's classes or `const` objects | Extract shared logic into a 3rd module (`config.ts`, `models.ts`) |
| **Barrel File Cycle** | Internal file imports a sibling via `index.ts` | Change to direct relative import (`./Button`) |
| **Complex Domain Logic** | Tightly coupled domain services | Apply 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:

- [How to Check Types in TypeScript (The Complete Guide)](https://danywalls.com/how-to-check-types-in-typescript)
- [3 Ways of Type Transformation in TypeScript](https://danywalls.com/3-ways-of-type-transformation-in-typescript)
- [Why Avoid Using 'any' in TypeScript](https://danywalls.com/why-avoid-using-any-in-typescript)
- [Combining Types and Interfaces with & or | in TypeScript](https://danywalls.com/combining-types-and-interfaces-with-or-in-typescript)

