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.
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:
// 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:
// 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.
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:
// 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:
// 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:
// 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.
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
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.
Frequently Asked Questions
Does TypeScript catch circular imports at compile time?
Are all circular imports bad?
How do barrel files create hidden circular dependencies?
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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.