ESM doesn't need to break the ecosystem
Benjamin E. Coe
Posted on May 1, 2021
tldr; ECMAScript modules do not need to represent a hard break for the JavaScript ecosystem. This post outlines an approach that can be taken by library authors for supporting a gradual migration.
Background
For the last decade, folks writing JavaScript with npm dependencies have grown accustomed to CommonJS syntax. Writing code that looks like this:
const yargs = require('yargs');
ECMAScript modules introduce a new syntax for importing dependencies, standardized by TC39 (the Technical Committee that oversees the JavaScript standard). The new syntax looks like this:
import yargs from 'yargs'
Along with the syntactic changes, there are other underlying differences between ESM and CommonJS which make the systems incompatible (see: "Node Modules at War: Why CommonJS and ES Modules Can’t Get Along").
A particularly important distinction is that,
In ESM, the module loader runs in asynchronous phases. In the first phase, it parses the script to detect calls to import and export without running the imported script.
- Dan Fabulich
If library authors have written code like this,
let cachedModule;
function getModule() {
if (cachedModule) return cachedModule;
cachedModule = require('optional-dependency');
}
it will need need to be rewritten when migrating to ESM, because the module loader is no longer synchronous.
Challenges in migrating to ESM
As alluded to in the Background section, migrating a library to ESM can be a challenge:
- You need to switch all of your
require
statements toimport
statements. - You may need to restructure chunks of your codebase, if you're using lazy
require
s. - Many of your dependents and dependencies may have not yet made the switch to ESM.
I see #3 as the biggest pain point that the JavaScript community will face during the awkward transitional phase from CommonJS to ESM.
There are benefits to migrating to ECMAScript modules, e.g., the ability to deliver code that runs on multiple JavaScript runtimes without a build step (Deno, modern web-browsers, Node.js).
However, for foundational libraries in the ecosystem, there's a significant risk associated with being an ESM "first mover". Library authors face the danger of splitting their userbase, and receiving a constant barrage of pressure to backport to previous CommonJS releases.
Dual CJS/ESM Modules (a way to avoid breaking the ecosystem).
In their article "Get Ready For ESM", Sindre Sorhus mentions an alternate approach to the hard switch to pure ESM modules which they themselves advocate, "Dual CommonJS/ES module packages".
I'm empathetic to Sindre's argument for ripping off the bandaid, but myself advocate the more conservative alternative of Dual CommonJS/ESM modules:
- It benefits library consumers, who may not be able to migrate their applications to ESM immediately.
- It benefits other library authors, who may not have the resources to immediately switch their libraries to ESM.
- In general, it helps smooth the ESM migration process for the JavaScript ecosystem.
Creating dual CJS/ESM modules
Yargs ships a dual CJS/ESM module using a combination of TypeScript, Rollup, and modern Node.js features, here's how:
- We added the
type: module
field to our package.json, to indicate that by default files with a.js
extension should be considered to be ECMAScript modules (this is a workaround for the fact that TypeScript does not currently support the.mjs
extension, and should be avoided if not using TypeScript, or once the issue is resolved). - We updated all of our
import
statements in TypeScript to include the absolute path to the source files, .e.g.,
import {maybeAsyncResult} from './utils/maybe-async-result.js';
This was for the benefit of Deno and web browsers.
- We set the
module
option in our TypeScript configuration toes2015
, indicating that ECMAScript modules should be generated during compilation. - We added a Rollup build step to yargs, which generates a
.cjs
bundle of our TypeScript code, here's what the configuration looks like:
const ts = require('@wessberg/rollup-plugin-ts');
const output = {
format: 'cjs',
file: './build/index.cjs',
exports: 'default',
};
const plugins = [
ts(),
];
module.exports = {
input: './lib/cjs.ts',
output,
plugins,
};
Note the
@wessberg/rollup-plugin-ts
dependency, this handles the translation between TypeScript and CommonJS.
- We added a conditional exports to
package.json
, providing hints about when to load our CommonJS, vs., ESM entry points.
{
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
},
"./helpers": {
"import": "./helpers/helpers.mjs",
"require": "./helpers/index.js"
}
}
}
Note: adding an exports map should be considered a breaking change, and you should ensure that you test the behavior of your the map on a variety of Node.js versions.
Conclusion
Getting a module working for both CommonJS and ECMAScript modules took quite a bit of fiddling (it was a pain in the neck honestly). But, I feel there's value in library authors considering this approach. We can help steward the JavaScript ecosystem into the future, without throwing out all of the wonderful work of the past.
-- Ben.
Ben was the third employee at npm, Inc, where he became involved with open-source and the Node.js community. Ben maintains the open source library yargs, is a collaborator on Node.js, and contributes to other projects, such as v8.
Posted on May 1, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.