← Debugging Guides

阅读中文版本

Node.js — Circular require Produces undefined Exports at Startup

Diagnose TypeError cannot read properties of undefined caused by CommonJS circular requires, read the partial export cache, and break the cycle with lazy requires or modules.

intermediatedebuggingnodejscommonjsmodules

Symptoms

  • TypeError: Cannot read properties of undefined (reading 'ROLE_ADMIN') during startup, before any request is served
  • A module works when required first in a REPL but breaks when the require order changes with the entry point
  • The failing import sits in a file that the target module also requires transitively

Possible causes

  • Module A requires B, and B requires A back, so A's module.exports is still the initial empty object when B runs
  • Constants are destructured at load time from a module that has not finished evaluating its exports yet
  • Class inheritance expressed through mutual requires between parent and child modules
  • A barrel index.js file re-exporting modules that require each other

Diagnosis steps

Confirm the cycle explicitly

node --trace-warnings -e "require('./src/a.js')" 2>&1 | head -40; madge --circular src/

Interpretation

madge lists the exact require cycle; Node itself warns when a module is in the cache before evaluation completes.

Inspect the partial export at the failure point

node -e "const b = require('./src/b.js')" # add console.log(module.exports) inside the failing module

Interpretation

Logging module.exports inside the not-yet-finished module shows {} or a subset, proving evaluation is incomplete rather than the export being absent.

Reproduce the order sensitivity

node -e "require('./src/b.js')" && node -e "require('./src/a.js')"

Interpretation

If each entry point makes a different module fail with undefined exports, the bug is order-dependent partial evaluation.

Root cause

CommonJS caches module.exports before evaluation finishes. In a require cycle, the module that is entered second receives the cached, still-empty exports object of the module that is mid-evaluation, so any load-time use of those exports reads undefined.

Fix

Break the cycle by extracting shared constants into a leaf module, moving requires from the top of the file into the functions that use them, or restructuring parent-child relations into a single module. For ESM migrations, ES module bindings tolerate cycles for function declarations but not for top-level value access.

Risk note

Lazily requiring inside hot paths adds resolution overhead per call and hides the dependency graph. Prefer extracting a leaf module when the cycle is structural.

Verify the fix

Require every entry point (server, jobs, scripts) after the refactor and confirm zero undefined-export errors, then run madge again and assert the circular list is empty.

Symptom walkthrough

The failure happens during evaluation, before the first request, and the message is misleading until you know the mechanism:

TypeError: Cannot read properties of undefined (reading 'ROLE_ADMIN')
    at Object.<anonymous> (src/permissions.js:9:32)

permissions.js destructured ROLES from a module that requires permissions.js back. Node documents the exact behavior: when a module is required while it is mid-evaluation, its cached module.exports is the initial, empty object. So the import is undefined at that moment even though the file eventually exports it correctly.

Run madge to name the loop instead of guessing:

npx madge --circular src/
# 1) src/permissions.js -> src/roles.js -> src/permissions.js

Fix in practice

Three options, in order of preference. First, extract the shared piece into a leaf that requires nothing:

src/roles-constants.js   <- leaf, required by both
src/permissions.js       <- requires the leaf only
src/roles.js             <- requires the leaf only

Second, move the require to the call site so it runs after both modules have finished evaluating:

// in permissions.js
function assertAdmin(user) {
  const { ROLES } = require('./roles.js'); // lazy: cycle-safe
  return user.role === ROLES.ROLE_ADMIN;
}

Third, merge mutually-referencing modules when they are really one concept. Avoid top-level destructuring of anything from a module you also require; read it through a function call instead.

Verification

After the refactor, require each application entry point in isolation: server, job runner, and scripts. Every boot must reach the listening state without the undefined error. Re-run madge –circular and require an empty list. Then add the madge check to CI with –warning, so a future commit that reintroduces a cycle fails before it reaches production, where entry-point order differences are harder to reason about.