the cover image of blog post JavaScript modules ESM vs CJS

JavaScript modules ESM vs CJS

2025-03-03
20 min read

Source of frustrations

CommonJS (CJS) and ECMAScript (ESM) are two major module systems in the JavaScript Eco system, CJS came first with Node.js and it has lasted for a long time then came the ESM which is said to be the future of standard due to compatibility and modern syntax.

To be honest, as a fullstack developer I have been stuck with the interopbability issue between ESM and CJS several times, after some searching it always turns out it needs to adjust one or some configs in the tsconfig, but it's never been a simple clear idea to fix the problem in my head until I got a deeper understanding about why these inconsistencies happened.

It's time for getting a thorough understanding about the things under the hood, let's dive in!

Revisit the module pattern and hosts

CJS

The syntax of Commonjs module mostly includes require and exports(or module.exports), also module require is a synchronous process, which often is one of the causes of the cyclic dependecies issue in the Commonjs systems. The essential require/exports usage looks like below,

/* dependant */ const a = require('./a') const { bar } = require('./b') a.foo() bar() /* dependency */ // @Filename: a.js function foo() { ... } module.exports = { foo: foo } // @Filename: b.js exports.bar = function () {...}

ESM

The syntax for the module system is with import and export, and it's worthy of note that import is asynchronous.

/* dependant */ import a, { n } from './a' import { bar } from './b' console.log(n) // 1 console.log(a) // hello bar() /* dependency */ // @Filename: a.ts export const n = 1 export default "hello" // @Filename: b.ts export function bar () {...}

Host matters

The TypeScript compiler needs to understand the host (the system ultimately consumes the transplied JavaScript) to determine the module strategies like output format, import type and relative module resolution(how the module file lookup works). Make sure to be clear that runtimes like Node.js and bundlers are hosts but TypeScript per se is not a host.

image

Module

Like mentioned earlier runtime(like Node.js) and bundlers(like Webpack) are hosts, knowing the host is crucial to understand a suitable configuration for the module system(i.e. module in tsconfig). There are quite a lot options but by checking the diagram below you probably want nodenext for modern Node.js projects and esnext for code that will be bundled. There are also some worthy of notes for Node.js project,

  • Node.js v12 and later accepts both CJS and ES modules
  • Node.js ESM uses file extensions and package.json file to determine what format each file should be( i.e. .mts/.mjs, .cts/.cjs or type: module in package.json)

image

Bear in mind the module is about the output format not the input format, so you see the input syntax looks like ESM but the output might vary.

image

ModuleResolution

First, module resolution is host-defined, TypeScritp doesn't modify the specifier(the name after the from keyword) during the emit; there are some available moduleResolution options, but if you have decided the module option, the countpart moduleResolution (ususally the default one) should work in most cases,

image

In the nutshell, TypeScript tries to model the host's module resolution algorithm between output files, it's important to understand the module resolution is all about output files(JavaScritp files in most cases). Let's go with the following exmaple.

// @Filename: index.ts import { foo } from './a' foo() // @Filename: a.ts export function foo() {...}

It looks like index.ts is looking for a.ts directly but it's not the real situation, the diagram below reveal a more accurate flow.

index.ts a.ts \ / \ / index.js --> a.js

It might appear much stranger in Node.js ESM, becuase Node.js uses a strick module resolution algorithm for import declaration so the specifier should always be with a file extension.(i.e. .ts,.mts or .mjs, etc.)

// @Filename: index.ts import { foo } from './a.mjs' foo() // @Filename: a.mts export function foo() {...}

From the above code, although there is no a.mjs file in the src folder at all, if you know the resolution happens in host(or you can think of the dist folder with transpiled files), it makes sense suddenly.

src/index.ts src/a.mts \ / \ / dist/index.js --> dist/a.mjs

Last thing about the module resolution is that if you use tsc you can inspect the resolution with option --traceResolution, this is extremely useful for debugging of resolution issues.

Interoperability

The needs of interoperability come from two parts.

  • ESM to CJS Transpilers
  • Numerous existing CJS modules in the JavaScript Eco system.

ESM to CJS Transpilers

Back to the 2015, when the ESM came out, ESM-CJS-Transpilers aimed to adopt ECMAScript new features and syntax but unfortunately some of them were not yet supported in runtimes, so they decided to downlevel ESM to CJS, so later on when those features get supported in new version runtime, the same code can be used as pure ESM smoothly without much migration hassle. This actually requires an almost one-to-one mapping between ESM and CJS.

A simple naive mapping could be like this, so far so good,

export const n = 1 --> exports.n = 1 export function greet() {} --> exports.greet = function () {} export default expression --> exports.default = expression

import can be mapped like below,

import { n } from './a' --> const { n } = require('./a') import { greet } from './a' --> const { greet } = require('./a') import a from './a' --> const a = require('./a').default import * as a from './a' --> const a = require('./a')

You might notice there is one pattern missing in the above mapping, in Commonjs, sometimes a primitive or function can be exported as module.exports directly, but we don't have an ESM syntax mapping for it yet.

module.exports = function () {...} // hmm, how to use ESM export to map it?

It is ok that your ESM won't produce such exports but how can we access these existing modules? import * as a from './a' seems to be an option, because it's mapped to a required whole exports directly, see below code,

// @Filename: a.ts module.exports = function () {...} // @Filename: index.ts import * as a from './a' a()

This works in runtime. However, there is a compliance issue, according to the JavaScript spec, a namespace import (import *) should resolve to a namespace object not a function.

Legacy modules(true CJS)

Putting the invalid above aside, there are quite a lot of legacy CommonJS modules in the Node.js Eco system, how does the ESM code(or EMS transpiled to CJS) interact with them? One subtle problem for the naive mapping mentioned earlier is about the default, because the true CJS(the modules writtern as CommonJS) has no concept of default, so import a from './a' could cause potential issues as there is no default at all.

  • But we do need a default import for those legacy modules as the namespace import(star import) for function is invalid according to the spec.
  • And, the runtimes have to choose a consistent way, namely, making the default import of CJS modules linked to the whole exports no matter if it's a primitive or function.
  • Then we need to differentiate true CJS from the ESM transpiled ones as we still need a default import for ES modules with export default, it will be more obvious with the following code snippets, if we can not distinguish a1 and a2 we don't have a consistent way to call the dependency.
// @Filename: a1.js (true CJS) module.exports = function () {...} // @Filename: index.ts import a from './a1' a() // it works as runtime now links the default import to `exports`. ---------- // @Filename: a2.js (transpiled CJS) exports.default = function () {...} // @Filename: index.ts import a from './a2' a() // as `a` is linked to `exports`, it's not a callable function! a.default() // it works with extra default

To differentiate the true CJS and the ESM-transplied CJS here comes the __esModule, by adding this special flag from ESM-CJS-Transpilers, default import could be applied to both with a conditional check.

// import a from "./a"; const mod = require("./a"); const a = mod.__esModule ? mod.default : mod;

TypeScript introduced the esModuleInterop flag to address the ES Module Interop problem with helper functions(__importDefault and __importStar), giving the following example,

// @Filename: index.ts import a, { x } from "./a"; import * as b from "./b"; console.log(a); console.log(b);

With esModuleInterop disabled, depending on if a is a written CJS or transpiled CJS, the a_1.default might cause issue.

// @Filename: index.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const a_1 = require("./a"); const b = require("./b"); console.log(a_1.default); console.log(b);

With esModuleInterop enabled, helper functions are added to the dependant.

// @Filename: index.js "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const a_1 = __importDefault(require("./a")); const b = __importStar(require("./b")); console.log(a_1.default); console.log(b);

Node.js interop

Node.js supports ESM in v12, just like bundlers(Babel for example) it provides a synthetic default export to exports object of the CJS modules. However unlike the bundlers, Node.js isn't able to respect the __esModule flag to vary the default import behaviour so transplied module behaves differently under Node.js ESM and other transplied modules.

// @Filename: a.js (transpiled CJS) exports.default = function () {...} // @Filename: index.ts import a from './a' // in Node.js ESM a.default() // double default... // in Another transpiled module a() // it works

Best practise

Interoperability is complicated, to keep a smooth migration from transpiled modules to the true ESM, there are some takeaways,

  • Set a proper module compiler option.
  • Applications with CommonJS should always enabled esModuleInterop.
  • Library that ships with CommonJS should not use default export to avoid confusion as the way the exports can be accessed varies.
© 2025 Xavier Zhou