
JavaScript modules ESM vs CJS
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.
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
ortype: module
in package.json)
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.
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,
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 distinguisha1
anda2
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.