ESM doesn't need to break the ecosystem
Tags: javascript, npm, esm
Published: 2021-05-01 21:17:07

_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](https://en.wikipedia.org/wiki/CommonJS). Writing code that looks like this:

```js
const yargs = require('yargs');
```

ECMAScript modules introduce a [new syntax for importing dependencies](https://tc39.es/ecma262/#prod-Module), standardized by TC39 (_the Technical Committee that oversees the JavaScript standard_). The new syntax looks like this:

```js
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"](https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1)).

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](https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1)**

If library authors have written code like this,

```js
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:

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 `require`s.
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"](https://blog.sindresorhus.com/get-ready-for-esm-aa53530b3f77), Sindre Sorhus mentions an alternate approach to the hard switch to pure ESM modules which they themselves advocate, _["Dual CommonJS/ES module packages"](https://nodejs.org/api/packages.html#packages_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](https://www.typescriptlang.org/), [Rollup](https://rollupjs.org/guide/en/), and [modern Node.js features](https://nodejs.org/api/packages.html#packages_conditional_exports), here's how:

* We added the `type: module` field to our [package.json](https://github.com/yargs/yargs/blob/master/package.json#L26), 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](https://github.com/microsoft/TypeScript/issues/18442)_, 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.,
   ```js
   import {maybeAsyncResult} from './utils/maybe-async-result.js';
   ```

> This was for the benefit of Deno and web browsers.

* We set the [`module`](https://www.typescriptlang.org/tsconfig/#module) option in our TypeScript configuration to `es2015`, indicating that ECMAScript modules should be generated during compilation.
* We added a [Rollup](https://rollupjs.org/guide/en/) build step to yargs, which generates a `.cjs` bundle of our TypeScript code, here's what the configuration looks like:
   ```js
   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`](https://github.com/wessberg/rollup-plugin-ts) dependency, this handles the translation between TypeScript and CommonJS.

* We added a [conditional exports](https://nodejs.org/api/packages.html#packages_conditional_exports) to `package.json`, providing hints about when to load our CommonJS, vs., ESM entry points.
  ```json
  {
    "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](https://twitter.com/BenjaminCoe).

> _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._