import/no-cycle

Disallow importing a module that imports this file back, directly or through other modules

Also called import-x/no-cycle

When two modules import each other, one of them runs before the other has finished. Its imports aren't initialized yet, so reading them throws a ReferenceError or gives undefined. Which module breaks depends on which one is loaded first.

The rule follows the imported module's imports as far as they go, so it also finds a cycle through several files. import type is ignored, because it's gone at runtime.

Configure#

Off by default. To turn it on:

bunfig.toml
[lint.rules]
"import/no-cycle" = "error" # or "warn"

Every option, set to its default. The ones that are commented out are unset by default.

bunfig.toml
[lint.rules]
"import/no-cycle" = ["error", {
  maxDepth = "∞", # number or "∞"
  ignoreExternal = false,
  allowUnsafeDynamicCyclicDependency = false,
  commonjs = false,
  amd = false,
  esmodule = true,
  # ignore = <list of strings>,
  disableScc = false,
}]

Examples#

Reported:

session.js
import { findUser } from "./user";

// ReferenceError if user.js is loaded first
export const currentUser = findUser(1);
user.js1 problem
import { currentUser } from "./session";

export const findUser = id => ({ id });
export const isCurrent = user => user.id === currentUser.id;

Not reported, because the code both files need is in a third one:

find-user.js
export const findUser = id => ({ id });
session.js
import { findUser } from "./find-user";

export const currentUser = findUser(1);
user.jsNo problems
import { currentUser } from "./session";

export const isCurrent = user => user.id === currentUser.id;

Options#

OptionDefaultWhat it does
maxDepth"∞"How many imports to follow. With 1, only a module that imports this file itself is reported.
ignoreExternalfalseDon't follow package imports.
allowUnsafeDynamicCyclicDependencyfalseAllow a cycle that has an import() somewhere in it.
commonjsfalseAlso check the require() calls in this file.
amdfalseAlso check define([...]) and require([...]) in this file.
esmoduletrueCheck import, export ... from and import().
ignore[]Regular expressions. An import path that matches one isn't checked.
disableSccfalseSkip the first pass that groups the modules that can reach each other. It only changes how fast the rule is.

Here the way back takes two steps. By default the import is reported with Dependency cycle via ./log:1, which means that ./session gets back to this file through its import of ./log on line 1. With maxDepth: 1 it isn't reported:

session.js
import { log } from "./log";

export const currentUser = log({ id: 1 });
log.js
import { isCurrent } from "./user";

export function log(user) {
  console.log(user.id, isCurrent(user));
  return user;
}
user.jswith { "maxDepth": 1 }No problems
import { currentUser } from "./session";

export const isCurrent = user => user.id === currentUser.id;

An import() inside a function only runs when the function is called, so it rarely causes the problem above. It's still reported by default. With allowUnsafeDynamicCyclicDependency: true it isn't:

session.js
import { findUser } from "./user";

export const currentUser = findUser(1);
user.jswith { "allowUnsafeDynamicCyclicDependency": true }No problems
export const findUser = id => ({ id });

export async function isCurrent(user) {
  const { currentUser } = await import("./session");
  return user.id === currentUser.id;
}

When not to use it#

A cycle is harmless if the two modules only use each other inside functions that run later. If your project has many cycles like that, the reports are noise.