Avoid Circular Imports in TypeScript

Circular imports happen when two modules depend on each other, causing runtime errors or incomplete values. Learn how to detect, break, and prevent import cycles.

7 min read

TypeScript circular imports happen when two modules depend on each other. Module A imports from module B, and module B imports from module A. The result is a cycle in the dependency graph that can cause a runtime error or an incomplete value, depending on whether the compiled output is an ES module or CommonJS.

Here is the simplest circular import. Module A and module B each try to use something from the other during their own initialization:

typescripttypescript
// a.ts
import { bValue } from "./b.js";
 
export const aValue = "A";
 
export function useB() {
  console.log(bValue);
}

Module A imports bValue before it defines its own aValue. Module B mirrors the same pattern, importing from module A before defining its own export:

typescripttypescript
// b.ts
import { aValue } from "./a.js";
 
export const bValue = "B";
 
export function useA() {
  console.log(aValue);
}

When a module imports from another, the imported module runs first. If a.ts is loaded first, it pauses when it hits the import from b.ts. Then b.ts runs and imports from a.ts, but a.ts has not finished executing yet, so the aValue binding is not yet initialized.

Circular import execution order

The diagram shows the problem clearly. When b.ts tries to use aValue, a.ts has not finished executing.

TypeScript compiles ES module output with live bindings, so reading aValue at this point does not silently return undefined. It throws ReferenceError: Cannot access 'aValue' before initialization, the same temporal dead zone error you get from reading a local const too early.

If your tsconfig targets CommonJS output instead, the failure mode is different: you get a partial exports object where the missing property reads as undefined instead of throwing. Either way, TypeScript reports no error because the types look correct, and the problem only surfaces at runtime.

Why Circular Imports Are Dangerous

Circular imports cause two categories of problems. The first is the undefined value problem described above. The second is more subtle and involves TypeScript types.

When TypeScript encounters a circular type reference, it may silently resolve the type to any or produce confusing error messages. This undermines the type safety the compiler is supposed to provide.

Even when the cycle does not cause a runtime error, it makes the codebase harder to reason about. A developer reading module A sees an import from module B, then opens module B and sees an import back to module A.

The mental model of how data flows through the code breaks down.

Patterns to Break Circular Imports

The most reliable fix is to extract the shared dependency into a third module. Both original modules import from the shared module instead of from each other. The dependency graph becomes a simple V-shape instead of a loop.

Here is the same example, fixed. The constant that both modules need moves to a shared file:

typescripttypescript
// shared.ts
export const aValue = "A";
export const bValue = "B";

Because shared.ts has no imports of its own, it always finishes loading before anything that depends on it. Now both modules import from shared.ts instead of from each other:

typescripttypescript
// a.ts
import { bValue } from "./shared.js";
 
export function useB() {
  console.log(bValue);
}

Module b.ts follows the same shape, importing aValue from the shared module rather than reaching back into a.ts the way it did before the fix:

typescripttypescript
// b.ts
import { aValue } from "./shared.js";
 
export function useA() {
  console.log(aValue);
}

The cycle is gone. The execution order is unambiguous: shared.ts loads first, then a.ts and b.ts can load in either order without issues.

Deferring the Import

When extraction is not practical, defer one side of the import until it is needed at runtime. Use a dynamic import inside a function instead of a top-level import. The dynamic import returns a promise that resolves to the module object.

Because the import happens inside a function that runs after module initialization, both modules have finished loading by the time the import executes. The cycle still exists in the dependency graph, but it no longer causes a ReferenceError or an incomplete value.

Dynamic imports should be a last resort. They add complexity, make the dependency harder to trace, and prevent the bundler from optimizing the import. Prefer extraction or restructuring.

Barrel Files and Hidden Cycles

Barrel files can create circular dependencies that are not visible in any single file. When one barrel re-exports from modules that import from another barrel, the cycle is invisible but still present.

Hidden cycle through barrel files

The cycle goes through four files, none of which directly import each other in an obvious cycle. The fix is to keep barrel files at module boundaries and never import from a barrel inside the same module group. Implementation files should import directly from sibling files, not through the barrel.

Detecting Circular Imports

Relying on code review to catch circular imports is unreliable. Use tooling to detect cycles automatically. The dpdm tool analyzes your project's dependency graph and reports all circular dependencies. Run it as a pre-commit check or in CI.

ESLint with the import plugin can also catch cycles. The no-cycle rule reports circular dependencies between files. Fix cycles as soon as they appear. A project with zero circular imports is easier to maintain.

Designing Against Cycles

Preventing circular imports starts with module design. Dependencies should flow in one direction: domain modules should not import from UI modules, and shared utilities should not import from feature modules.

Feature modules should not import from each other without a genuine hierarchical relationship.

This does not require a formal framework. It is a team convention. When a developer writes an import that goes against the convention, a code reviewer flags it. Over time, the convention becomes second nature and cycles become rare.

For more on structuring imports, see organize TypeScript project folders and module import mistakes in TypeScript.

Barrel files deserve extra caution with circular imports. For the full pattern, see barrel files in TypeScript.

Rune AI

Rune AI

Key Insights

  • Circular imports happen when module A imports from module B and module B imports from module A.
  • The second module to load sees an incomplete version of the first, with exports that are not yet initialized.
  • Break cycles by extracting shared code into a third module that both original modules import.
  • Barrel files can hide circular dependencies. Never import from a barrel inside the same module group.
  • Use madge or dpdm to detect cycles in your project before they cause runtime issues.
RunePowered by Rune AI

Frequently Asked Questions

Does TypeScript catch circular imports at compile time?

TypeScript detects some cases of circular type references but does not catch all circular imports. Value-level circular imports that cause runtime errors or incomplete values are often silent at compile time. Use a tool like madge or dpdm to detect them.

Are all circular imports bad?

Circular type-only imports are usually harmless because types are erased at compile time. Circular value imports that execute at module load time are dangerous because one side of the cycle gets an incomplete module object.

How do barrel files create hidden circular dependencies?

When barrel A re-exports from modules that import from barrel B, and barrel B re-exports from modules that import from barrel A, the cycle is invisible in any single file. Keep barrel files at module boundaries.

Conclusion

Circular imports create unpredictable runtime behavior when modules reference each other before initialization completes. Break cycles by extracting shared code into a separate module, using dynamic imports for deferred dependencies, or restructuring your module graph. Use tooling to detect cycles before they reach production.