JavaScript ESM & CommonJS Interop: Importing Between Module Systems

JavaScript has two major module systems in everyday use: ECMAScript modules, usually called ESM, and the older CommonJS format. This article explains how they work together, what you can import from each one, and why module interop can fail in real projects.

Quick answer: In Node.js, ESM can usually import CommonJS modules, but CommonJS cannot synchronously require() most ESM modules. The safest mental model is: ESM can often consume CommonJS, while CommonJS often needs dynamic import() to load ESM.

Difficulty: Intermediate

You'll understand this better if you know: basic import and require() syntax, how modules export values, and the difference between synchronous and asynchronous code.

1. What Is ESM & CommonJS Interop?

ESM and CommonJS interop is the set of rules that let code written in one module system use code written in the other. In practice, this matters most in Node.js projects that still depend on older packages while gradually moving to modern import and export syntax.

For beginners, the key idea is that a module system is not just syntax. It also affects when modules load, how values are exposed, and what shape the imported object has.

2. Why ESM & CommonJS Interop Matters

Most JavaScript projects do not live in a perfect world where every dependency uses the same module format. You may work in an older Node.js codebase, install a package that still publishes CommonJS, or switch one file at a time to ESM.

This topic matters because module mismatches can break applications at startup, during bundling, or only after deployment. Knowing the interop rules helps you:

Interop also matters for package authors. If you publish libraries for Node.js, you need to know how consumers will import your package from both module systems.

3. Basic Syntax or Core Idea

There are two main module styles to compare.

ESM syntax

export const pi = 3.14159;

export default function areaOfCircle(radius) {
  return pi * radius * radius;
}

ESM exposes values with named exports and optionally one default export.

CommonJS syntax

const pi = 3.14159;

function areaOfCircle(radius) {
  return pi * radius * radius;
}

module.exports = areaOfCircle;

CommonJS exports a value by assigning to module.exports. That value may be a function, an object, a class, or anything else.

The core interop idea

When ESM imports CommonJS, it usually treats the CommonJS export as a single default-like value. When CommonJS loads ESM, it cannot use plain synchronous require() in the same way, because ESM loading is asynchronous under the hood.

4. Step-by-Step Examples

Example 1: ESM importing a CommonJS default export

Suppose a CommonJS package exports one function using module.exports. ESM can import that value directly as the default import.

// math.cjs
function multiply(a, b) {
  return a * b;
}

module.exports = multiply;

// app.mjs
import multiply from "./math.cjs";

console.log(multiply(2, 4)); // 8

This works because Node.js maps the CommonJS export object to the ESM default import in the common case.

Example 2: ESM importing a CommonJS object with properties

If a CommonJS module exports an object, ESM still receives that object as the default export.

// config.cjs
module.exports = {
  port: 3000,
  start() {
    return "server started";
  }
};

// app.mjs
import config from "./config.cjs";

console.log(config.port); // 3000
console.log(config.start()); // server started

In ESM, the imported value is the whole CommonJS export object, not a set of named exports created automatically from its properties.

Example 3: CommonJS loading ESM with dynamic import

A CommonJS file cannot normally use require() to load an ESM file. Instead, it must use dynamic import(), which returns a promise.

// logger.mjs
export function log(message) {
  return `LOG: ${message}`;
}

// app.cjs
(async () => {
  const logger = await import("./logger.mjs");
  console.log(logger.log("hello"));
})();

Here, import() gives CommonJS access to the ESM module namespace object after the module loads.

Example 4: Accessing named exports from a CommonJS namespace object

Sometimes a CommonJS module sets multiple properties on module.exports. From ESM, you still usually read those properties from the default import object.

// shapes.cjs
module.exports = {
  square(x) {
    return x * x;
  },
  cube(x) {
    return x * x * x;
  }
};

// app.mjs
import shapes from "./shapes.cjs";

console.log(shapes.square(5)); // 25
console.log(shapes.cube(3)); // 27

This pattern is common when moving a utility library from CommonJS to ESM gradually.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Using require() on an ES module

CommonJS code often tries to load an ESM file the same way it loads older packages. That breaks because ESM is not designed to be synchronously loaded with require().

Problem: Node.js typically throws ERR_REQUIRE_ESM or a similar module-loading error when you try to require() an ES module.

// app.cjs
const logger = require("./logger.mjs");

Fix: Use dynamic import() from CommonJS.

// app.cjs
(async () => {
  const logger = await import("./logger.mjs");
  console.log(logger.log("hello"));
})();

The corrected version works because import() loads ESM asynchronously, which matches the module system's behavior.

Mistake 2: Expecting named imports from a CommonJS export object

Many developers assume CommonJS properties become named exports automatically in ESM. That is not the safest assumption and often leads to confusing import errors or undefined values.

Problem: The module does not actually define ESM named exports, so importing them as if it did can fail or behave unexpectedly depending on the package and loader.

// shapes.cjs
module.exports = {
  square(x) {
    return x * x;
  }
};

// app.mjs
import { square } from "./shapes.cjs";

Fix: Import the CommonJS default value and read its properties.

// app.mjs
import shapes from "./shapes.cjs";

console.log(shapes.square(5));

This works because the exported object is what ESM receives, not a set of guaranteed named bindings.

Mistake 3: Assuming default means the same thing in both systems

ESM default export syntax and CommonJS module.exports can look similar in usage, but they are not identical. Confusing them causes import shape problems.

Problem: A CommonJS file does not understand ESM import syntax without being treated as an ESM file, and a default export from ESM is not the same as assigning to module.exports.

// wrong in a CommonJS file
import multiply from "./math.cjs";

Fix: Use the syntax that matches the file type, or convert the file to ESM.

// app.cjs
const multiply = require("./math.cjs");
console.log(multiply(2, 4));

The corrected version works because CommonJS uses require() and module.exports consistently.

7. Best Practices

Prefer one module system per package when possible

Mixed-module packages are harder to reason about and test. When you can, choose ESM for new code or keep a clearly separated CommonJS boundary for legacy code.

// better: keep the package internally consistent
export function sum(values) {
  return values.reduce((total, value) => total + value, 0);
}

Use import() at the boundary, not everywhere

If only one old dependency is CommonJS or one new dependency is ESM, isolate the compatibility code in a small adapter file. That keeps the rest of your code cleaner.

// adapter.cjs
module.exports = async function loadLogger() {
  return import("./logger.mjs");
};

Be explicit about file types and package intent

In Node.js, file extension and package configuration affect module interpretation. Use .mjs for ESM and .cjs for CommonJS when you need clarity during migration.

// clear file intent during migration
// utils.mjs - ESM
export const version = "1.0.0";

// legacy.cjs - CommonJS
module.exports = { version: "1.0.0" };

8. Limitations and Edge Cases

9. Practical Mini Project

In this mini project, you will build a small CommonJS utility and consume it from an ESM application. This mirrors a common migration scenario.

Below, the utility is still CommonJS, while the app file uses modern ESM syntax.

// format.cjs
module.exports = {
  formatPrice(amount) {
    return `$${amount.toFixed(2)}`;
  },
  formatLabel(name) {
    return `Item: ${name}`;
  }
};

// app.mjs
import formatters from "./format.cjs";

const priceText = formatters.formatPrice(19.5);
const labelText = formatters.formatLabel("Notebook");

console.log(priceText);
console.log(labelText);

Expected output:

$19.50
Item: Notebook

This project shows the most practical interop pattern: ESM imports a CommonJS default object and uses its methods normally.

10. Key Points

11. Practice Exercise

Expected output:

hello
HELLO

Hint: export an object from CommonJS, then read its methods from the ESM default import.

// strings.cjs
module.exports = {
  trimText(value) {
    return value.trim();
  },
  upperText(value) {
    return value.toUpperCase();
  }
};

// app.mjs
import strings from "./strings.cjs";

const input = "  hello ";

console.log(strings.trimText(input));
console.log(strings.upperText(strings.trimText(input)));

12. Final Summary

ESM and CommonJS interop is about understanding how two different module systems exchange values. The most important rule is that ESM commonly consumes CommonJS as a default-like import, while CommonJS usually needs dynamic import() to load ESM.

Once you understand the direction of compatibility, the rest becomes easier to predict. Many import problems come from assuming the two systems behave the same, especially around default exports, named exports, and synchronous loading.

For real-world work, keep module boundaries small, be explicit about file types, and test interop behavior in the same runtime where your application will run. If you are migrating an older codebase, start by isolating compatibility code so the rest of your project can move forward safely.

A good next step is to learn how Node.js decides whether a file is ESM or CommonJS, especially the role of package.json, file extensions, and the exports field.