ESM doesn't need to break the ecosystem

bcoe

Benjamin E. Coe

Posted on May 1, 2021

ESM doesn't need to break the ecosystem

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');
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. You need to switch all of your require statements to import statements.
  2. You may need to restructure chunks of your codebase, if you're using lazy requires.
  3. 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';
Enter fullscreen mode Exit fullscreen mode

This was for the benefit of Deno and web browsers.

  • We set the module option in our TypeScript configuration to es2015, 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,
   };
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
bcoe
Benjamin E. Coe

Posted on May 1, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related