cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

CommonJS & ES module interop

Learn how current Node.js chooses CommonJS or ES modules, how require and module.exports interact with import and export, why require(esm) has caveats, and how to avoid dual package hazards.

By the end you can
  • 01
    Predict the loaderUse extension, package type, and flags to tell whether Node runs a file as CommonJS or ESM.
  • 02
    Read interop safelyExplain default CommonJS imports, detected named exports, and require(esm) namespace results.
  • 03
    Avoid package trapsRecognize dual package hazards and choose safer wrapper or ESM-only designs.

Two module worlds

Node.js grew up with CommonJS: a file calls require("./tool"), Node runs that other file immediately if needed, and the call returns module.exports. Modern JavaScript has ES modules: files declare import and export, then the loader reads and links the whole graph before module bodies run.

You already met why modules exist in Why modules?, and you practiced standard import & export. This lesson is the bridge lesson for Node projects that contain both worlds. Browsers do not run CommonJS directly; every CommonJS claim here is phrased as Node behavior and backed by real Node runs in the tests.

Real-life analogyA set menu, served or read first

With CommonJS, you order the set menu and receive it before the next step. With ES modules, the loader reads all the menu items first, checks every import, then runs module code in order.

In real life: A set menu served when you order
In JavaScript: require() runs and returns module.exports at run time
In real life: Menu items read before the kitchen opens
In JavaScript: ES modules know imports and exports before code runs
In real life: The printed menu can change
In JavaScript: ES module bindings stay live

Where the analogy stops: A real menu does not run code. The comparison is only about when CommonJS returns and when ES modules link.

Working definition

CommonJS and ES module interop means crossing between CommonJS (require and module.exports) and ES modules (import and export) in Node while keeping the loader rules, cache rules, and edge cases honest.

How Node chooses a loader

INTERACTIVE

The first question is not “does the file contain import?” The first question is “which loader did Node choose for this file?” The answer comes from explicit file extensions, the nearest package.json type field, and for string input, flags such as --input-type=module.

Which loader runs this file?
Node's decision tree modelPop out in the code editor (opens in a new tab)JavaScript
function decide({ file, nearestType, inputTypeFlag }) {  if (inputTypeFlag) return inputTypeFlag;  if (file.endsWith(".cjs")) return "CommonJS";  if (file.endsWith(".mjs")) return "ES module";  if (nearestType === "module") return "ES module";  return "CommonJS";}
DecisionES module
fileapp.js
nearest typemodule
loaderES module

A nearest package.json with type module makes .js files ES modules.

Try it yourself

app.js runs as ES module. A nearest package.json with type module makes .js files ES modules.

This is a faithful model of Node's file kind decision. Browser pages do not run CommonJS files directly.
Node's file-kind rules
FileNearest package.json typeNode loader
tool.cjsAnyCommonJS
tool.mjsAnyES module
tool.jsmoduleES module
tool.jscommonjs or no typeCommonJS

A package can be mostly ESM with type: "module" and still keep a config file as tool.cjs. Or it can be mostly CommonJS and keep one explicit tool.mjs. That is why file extension examples in bug reports matter.

require and module.exports

STEP THROUGH

CommonJS wraps each file in a function that receives local variables such as require, module, exports, __dirname, and __filename. The public value is module.exports. At the beginning, exports is just a shortcut to that same object.

Step through CommonJS loading
Step 0 of 5Ready
Your turn: follow the blue line

Replay the first synchronous CommonJS load.

Running in
  1. script
Next: line 2
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
// main.cjsconsole.log(cart.total([2, 3])); // cart.cjsconst math = require("./math.cjs");exports.total = (items) => items.reduce(math.add, 0); // math.cjsexports.add = (a, b) => a + b;
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the require situation.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The cache is both helpful and surprising. It means expensive setup runs once. It also means stateful modules share one object. During a circular require, Node has to put a module in the cache before it finishes; the other module may see a partially filled exports object.

module.exports vs exports
The exports shortcutPop out in the code editor (opens in a new tab)JavaScript
// broken.cjsexports.answer = 42;exports = { answer: 0 };console.log(module.exports.answer); // replacement.cjsmodule.exports = function greet(name) {  return "Hello, " + name;};
OutputCommonJS alias
module.exports.answer42
local exports.answer0
Try it yourself
Read the two assignments, then compare the result.

Property writes through exports reach module.exports; reassigning exports only moves the local shortcut.

This is a model of the CommonJS wrapper parameters Node passes to each CommonJS file.

Interop rules

INTERACTIVE

Interop is a plug adapter between two shapes. It works, but only the pins the adapter can see fit cleanly. The reliable direction is import pkg from "./lib.cjs": pkg is the CommonJS module.exports value. Named imports from CommonJS are a convenience, not a guarantee.

Real-life analogyA plug adapter

If a CommonJS file writes exports.answer = 42, Node can often detect the answer pin. If it builds an object dynamically and assigns module.exports later, the adapter may not know that answer exists as a named ES export.

In real life: The adapter lets a plug connect
In JavaScript: ESM can import CommonJS, and CommonJS can require some ESM
In real life: A pin fits a matching hole
In JavaScript: Named CJS exports need static detection
In real life: The main plug always fits
In JavaScript: A default import is the whole module.exports

Where the analogy stops: A physical adapter does not inspect source code. Node's CommonJS named export bridge does, and dynamic patterns may be invisible to it.

Interop matrix
ESM imports CommonJS defaultJavaScript
// lib.cjsexports.answer = 42;module.exports.extra = "visible"; // app.mjsimport pkg, { answer, extra } from "./lib.cjs";console.log(pkg.answer, answer, extra); // require-esm.cjsconst ns = require("./tool.mjs");console.log(ns.default, ns.value);
Current Node resultworks
prints or throws42

The default import is the whole module.exports value.

Try it yourself

works: The default import is the whole module.exports value.

These results were checked against current Node 22 behavior while authoring and are regression-tested with real child Node processes.
Interop matrix for current Node releases
DirectionWhat you receiveCaveat
ESM imports CommonJS defaultThe exact module.exports valueReliable.
ESM imports CommonJS namedStatically detected propertiesNot every module.exports pattern is detected.
CommonJS requires ESMA module namespace objectCurrent Node releases only; fails for top-level await.
ESM needs requireUse createRequire(import.meta.url)ESM does not have require as a built-in local.

require(esm) in current Node

NODE 22

Older advice often says “CommonJS cannot require ES modules.” In current Node 22 releases, the precise rule is narrower: a CommonJS file can require() a synchronous ES module and receives its module namespace object. If the ES module graph uses top-level await, synchronous require cannot wait for it and Node throws ERR_REQUIRE_ASYNC_MODULE.

Current Node wording

Say “in current Node releases, require(esm) works for synchronous ES modules and fails for top-level await.” If you need an async ES module from CommonJS, use dynamic import(). The Dynamic import & top-level await lesson covers that async tool.

The namespace object returned by require("./tool.mjs") is shaped like import * as ns: default export on ns.default, named exports on their names, and no mutable CommonJS exports shortcut.

Dual packages

INTERACTIVE

Packages can publish an exports map with conditions. A consumer using import may receive one file; a consumer using require may receive another. That can be convenient during migration, but stateful code can split into two copies.

Real-life analogyTwo phone contact lists

If your phone has two contact lists for the same person, updating one does not change the other. A dual package can do that with module state when import and require point at different files.

In real life: Two contact lists for one person
In JavaScript: An import entry and a require entry create separate module instances
In real life: One list gets a new number
In JavaScript: The ESM counter changes
In real life: The other list stays the same
In JavaScript: The CommonJS counter did not share that state

Where the analogy stops: Files are not literally copied on disk. The hazard is runtime identity: two entry files can create two caches, counters, classes, or singletons.

Dual package resolver
package.json exports conditionsJSON
{  "name": "address-book",  "exports": {    "import": "./counter.mjs",    "require": "./counter.cjs",    "default": "./counter.mjs"  }}
Resolver and hazard./counter.mjs
matched conditionimport
CJS counter1
ESM counter2
same instance?false
Try it yourself

import matches the import condition and resolves to ./counter.mjs. If import and require get separate stateful files, the counters split.

A wrapper fix makes one side load the other implementation; an ESM-only package removes the split entry point.

Safer designs make one side a wrapper over the other, or publish ESM-only when your audience can use it. The key is one implementation for one piece of state.

Node-specific tools

CommonJS files have local helpers that ES modules do not: require, __dirname, and __filename. In modern Node ES modules, use import.meta.dirname, import.meta.filename, and createRequire(import.meta.url) from node:module when you truly need a CommonJS-style loader.

CommonJS locals and ES module replacementsPop out in the code editor (opens in a new tab)JavaScript
// CommonJSconsole.log(typeof require, typeof __dirname, typeof __filename); // ES moduleimport { createRequire } from "node:module";const require = createRequire(import.meta.url);console.log(import.meta.dirname, import.meta.filename);console.log(typeof require);

This is Node-specific. A browser ES module has import.meta.url, but not Node’s filesystem helpers, and a browser still does not run require() without a bundler or loader transforming the code first.

Where you’ll use this

You will use these rules when a test config says “Cannot use import statement outside a module,” when a package README shows const tool = require("tool") but your app is ESM, when a package throws “Named export not found,” or when a dual package creates two copies of a singleton.

A safe CommonJS import from ESMPop out in the code editor (opens in a new tab)JavaScript
import legacy from "legacy-commonjs-package";const { parse, format } = legacy; export function render(input) {  return format(parse(input));}

The pattern is boring on purpose: default-import the CommonJS package, then destructure. It avoids relying on Node’s named export detection and keeps the migration easy to read.

Common misconceptions

SORTER
  • “Browsers run CommonJS.” Browsers run classic scripts and ES modules. CommonJS needs Node or a build step.
  • “Every CommonJS property is a named ESM export.” Named CJS imports depend on static detection.
  • “exports = replaces the export.” It only reassigns the local shortcut. Use module.exports = ....
  • “type: module changes every extension.” It changes .js; .cjs and .mjs stay explicit.
  • “Dual packages are always safe.” They are safe only when both entry points share implementation and state.
Interop sorter
  • import pkg from "./lib.cjs";
  • import { answer } from "./lib.cjs"; // lib uses exports.answer = 42
  • import { answer } from "./lib.cjs"; // lib does module.exports = { answer: 42 }
  • const ns = require("./tool.mjs"); // no top-level await
  • require("./async-tool.mjs"); // async-tool has top-level await
  • const require = createRequire(import.meta.url);
Try it yourself
0 of 6 correct

Sort each snippet by what current Node does.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict an exports property write

Run the code mentally. What does the CommonJS model print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const module = { exports: {} };
const exports = module.exports;
exports.answer = 42;
console.log(module.exports.answer);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeTrace the cache

    Predict both values printed by this cache model.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const cache = {};
    function load() {
      if (cache.tool) return cache.tool;
      cache.tool = { count: 0 };
      return cache.tool;
    }
    const a = load();
    const b = load();
    a.count += 1;
    console.log(a === b, b.count);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFix a fragile named CommonJS import

      Replace the first import line with a safer bridge.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // lib.cjs
      module.exports = { answer: 42 };
      
      // app.mjs
      import { answer } from "./lib.cjs";
      console.log(answer);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · ChallengeSpot the dual package hazard

        Predict the split counter output.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const cjsCounter = { count: 0 };
        const esmCounter = { count: 0 };
        cjsCounter.count += 1;
        esmCounter.count += 1;
        esmCounter.count += 1;
        console.log(cjsCounter.count + "/" + esmCounter.count);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeName the async require error

          Which Node error code appears?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // async-tool.mjs
          await Promise.resolve();
          export const value = 1;
          
          // app.cjs
          require("./async-tool.mjs");

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which loader runs a .cjs file in a package with type: module?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does exports.answer = 42 update?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const module = { exports: {} };
              const exports = module.exports;
              exports.answer = 42;
              console.log(module.exports.answer);

              Choose an answer to see the explanation.

            3. Question 3 of 8When ESM imports a CommonJS module with a default import, what is the default value?

              Choose an answer to see the explanation.

            4. Question 4 of 8Which CommonJS-to-ESM case fails in current Node releases?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does the cache model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const cache = {};
              function load() {
                if (cache.tool) return cache.tool;
                cache.tool = { count: 0 };
                return cache.tool;
              }
              const a = load();
              const b = load();
              a.count += 1;
              console.log(a === b, b.count);

              Choose an answer to see the explanation.

            6. Question 6 of 8Why can a dual package create stale state?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does the dual counter model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const cjsCounter = { count: 0 };
              const esmCounter = { count: 0 };
              cjsCounter.count += 1;
              esmCounter.count += 1;
              esmCounter.count += 1;
              console.log(cjsCounter.count + "/" + esmCounter.count);

              Choose an answer to see the explanation.

            8. Question 8 of 8Which ES module replacement gives you a CommonJS-style require?

              Choose an answer to see the explanation.

            Key takeaways

            • .cjs means CommonJS; .mjs means ESM; .js follows the nearest package type.
            • require() is synchronous and returns module.exports; CommonJS modules are cached by resolved filename.
            • ESM default-imports CommonJS as the whole module.exports; named imports from CommonJS have detection caveats.
            • In current Node releases, require(esm) can return a namespace for synchronous ESM and throws for top-level await.
            • Dual packages need one shared implementation or they can create two copies of state.

            Final definition.
            CommonJS and ESM interop is Node’s set of adapter rules for crossing between synchronous CommonJS (require, module.exports) and statically linked ES modules (import, export).

            Up next: Proxy.

            CompleteFrontend Clear concepts. Working examples.