Portrait Dr. Axel Rauschmayer
Dr. Axel Rauschmayer
Homepage | Twitter
Cover of book “Exploring ES6”
Book, exercises, quizzes
(free to read online)
Logo of newsletter “ES.next news”
Newsletter (free)
Cover of book “JavaScript for impatient programmers”
Book (free online)

Hybrid npm packages (ESM and CommonJS)

(Ad, please don’t block)

In this blog post, we look at npm packages that contain both ES modules and CommonJS modules:

  • Current solutions
  • Future solutions (once ESM is unflagged in Node.js)

Required knowledge: How ES modules are supported natively on Node.js. You can read this blog post if necessary.

Warning: This area is still under active development, so some things may still change.


Table of contents:


Status quo  

It’s great to see that many npm packages already come with ESM versions. The most popular approach seems to be to have the following two lines in mypkg/package.json:

"main": "./commonjs/entry.js",
"module": "./dist/mypkg.esm.js",

The first line is the CommonJS entry point into the package. The second line points bundlers (which are mainly used for browsers) to an ESM bundle of all of the code in this package.

The files in this package are:

mypkg/
  package.json
  commonjs/
    entry.js
    util.js
  dist/
    mypkg.esm.js  # ESM bundle

On Node.js, this package is used as follows:

const {x} = require('mypkg');

In bundled browser code, this package is used as follows:

import {x} from 'mypkg';

Most bundlers can also handle CommonJS modules and compile them to browser code.

Future solutions (with native ESM on Node.js)  

Native support for ES modules in Node.js:

  1. Node.js 12+ supports ESM natively behind the flag --experimental-modules
  2. Node.js 13.x (with x > 0) will support native ESM without that flag.

Terminology:

  • I’m using the term ESM Node.js for both ways of supporting ESM natively.
  • I’m using the term pre-ESM Node.js for versions of Node.js that do not support ESM natively.

With ESM Node.js, we have new options for implementing hybrid packages.

Option 1: maximum backward compatibility (CommonJS first)  

Scenario: We want to make it easy for clients of our package to upgrade from an existing CommonJS-only version to a hybrid version.

The hybrid version has the following files:

mypkg/
  package.json
  esm/
    entry.js
    util.js
  commonjs/
    package.json
    entry.js
    util.js

mypkg/package.json:

{
  "type": "module",
  "main": "./commonjs/entry.js",
  "exports": {
    "./esm": "./esm/entry.js"
  },
  "module": "./esm/entry.js",
  ···
}

Notes:

  • "type": "module" means that .js files are interpreted as ESM. We want ESM to be the default, not CommonJS.
  • "exports" defines package exports, which enable the module specifier 'mypkg/esm' for the ES module (without a filename extension, which is normally required when using a deep import path to import an ES module).

mypkg/commonjs/package.json:

{
  "type": "commonjs"
}

Note:

  • "type": "commonjs" overrides the default module type. Now all files anywhere inside mypkg/commonjs/ are interpreted as CommonJS.

Support for CommonJS  

The CommonJS part of the package is used like this (natively on Node.js and if a bundler supports CommonJS):

const {x} = require('mypkg');

The main (bare import) entry point for this package is "./commonjs/entry.js". As a result, the module specifier of the hybrid version of this package is still 'mypkg' (unchanged from the CommonJS-only version).

Interpretation of .js files:

  • ESM Node.js: mypkg/commonjs/package.json ensures that .js files are interpreted as CommonJS modules.
  • Pre-ESM Node.js: always interprets .js as CommonJS.

Support for ESM  

The ESM part of the package is used like this (natively on ESM Node.js and in browsers):

import {x} from 'mypkg/esm';

Entry points:

  • ESM Node.js: Due to "type": "module" in mypkg/package.json, Node.js interprets the .js file pointed to by "./esm" as an ES module.
  • Pre-ESM Node.js: must use the CommonJS entry point (see above).
  • Modern bundlers can use the same entry point as ESM Node.js.
  • Older bundlers use the entry point specified via "module".

Option 2: ESM first, with a focus on backward compatibility  

Scenario: We are creating a completely new package and want it to be both hybrid and as backward compatible as possible. Now we can give preference to ESM and use the bare import 'mypkg' for that module format.

Our package now looks like this:

mypkg/
  package.json
  esm/
    entry.js
    util.js
  commonjs/
    package.json
    index.js  # entry
    util.js

mypkg/package.json:

{
  "type": "module",
  "main": "./esm/entry.js",
  "module": "./esm/entry.js",
  ···
}

mypkg/commonjs/package.json:

{
  "type": "commonjs"
}

This package is used as follows:

import {x} from 'mypkg';
const {x} = require('mypkg/commonjs');

The module specifier 'mypkg/commonjs' is an abbreviation for 'mypkg/commonjs/index.js'. This kind of abbreviation is enabled by the filename index (a feature that is only available for CommonJS, not for ESM). That’s why entry.js was renamed to index.js.

Option 3: ESM first, more filename extensions  

Scenario: Our package is new and should be hybrid, but we now have the luxury of only targeting modern bundlers ESM Node.js

Therefore, we can use the filename extensions .mjs (ESM) and .cjs (CommonJS) to indicate module formats. I like being able to distinguish them from .js script files (bundles for pre-module browsers, etc.).

That looks as follows:

mypkg/
  package.json
  esm/
    entry.mjs
    util.mjs
  commonjs/
    index.cjs  # entry
    util.cjs

mypkg/package.json:

{
  "type": "commonjs",
  "main": "./esm/entry.mjs",
  ···
}

Notes:

  • "type": "commonjs" isn’t really needed here because it’s the default (and because there aren’t any .js files). But it’s now recommended to always add a "type", in order to help bundlers and other tools understand the files in a package.

    • Benefit of interpreting .js files as CommonJS: It’s easier to move old .js CommonJS files into this package.
  • We can omit the following property because we don’t have to support older bundlers (which need it).

    "module": "./esm/entry.js"
    

ESM and CommonJS are used the same way as in option 2:

import {x} from 'mypkg';
const {x} = require('mypkg/commonjs');
  • ESM Node.js can use either ESM or CommonJS.
  • Modern bundlers can use the same ESM and CommonJS entry points as ESM Node.js.

Variation: compatibility with pre-ESM Node.js  

Scenario: We want to support pre-ESM Node.js. A modern bundler is still a requirement (due to the filename extension .mjs).

We support pre-ESM Node.js by switching to .js in directory commonjs/.

commonjs/
  index.js  # entry
  util.js

This also works in ESM Node.js, due to "type": "commonjs" in package.json.

Other ways of publishing both ESM and CommonJS  

The Node.js docs suggest two approaches for hybrid packages:

  1. The approach we used for option 1.
  2. Introducing a breaking change and using one of the approaches shown in this blog post.

Other possibilities:

  • Creating separate packages for ESM and CommonJS.

  • I still like the idea of artifact dimensions: The same package does not just have multiple versions, but also multiple artifact dimensions per version. Possible dimensions:

    • TypeScript source code
    • ES module source code
    • CommonJS source code
    • Type definition files (.d.ts etc.)

    Benefit: downloads would become smaller, because we would only download what we need.

    On the other hand, on-demand downloading of package files seems to be on the roadmaps of both npm and yarn, so maybe including everything in packages won’t be an issue in the future.

Acknowledgements  

Among others, the following people provided important input for this blog post:

Further reading