Hybrid npm packages (ESM and CommonJS)

[2019-10-22] dev, javascript, jsmodules, nodejs, esm, commonjs
(Ad, please don’t block)
Warning: This blog post is outdated. It may contain incorrect information.

Update 2019-11-22:

  • ESM support in Node.js is not experimental, anymore. This post was updated to reflect that.
  • Conditional exports are now explained.

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

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

Legacy approach for putting ES modules on npm  

npm packages have come with ESM versions for a long time. The most popular legacy 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.

ES modules on npm for Node.js with built-in ESM and browsers  

Native support for ES modules in Node.js:

  1. Node.js 12+ supports ESM natively behind the flag --experimental-modules
  2. Node.js 13.2.0+ supports 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 (experimental, needs conditional exports): ESM and CommonJS are both bare imports  

Scenario: We want to make it easy for clients of our package to upgrade from an existing CommonJS-only version to a hybrid version. Therefore, the existing CommonJS module specifier should not change. We also want ESM importers to use the same module specifier as CommonJS importers.

Caveat: This scenario is enabled by conditional exports which are still experimental and must be switched on via --experimental-conditional-exports.

Files in the package:

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

mypkg/package.json:

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

Notes:

  • "type": "commonjs" means that .js files are interpreted as CommonJS. For pre-ESM Node.js, we need .js to be CommonJS.
  • "main" defines the entry point for pre-ESM Node.js.
  • "exports" defines package exports. Package exports enable us to override "main" and to define deep import paths without filename extensions (which are normally required when using deep import paths to import ES modules).
    • The path "." overrides the outer "main". Its value can either be a string that points to a module or an object with conditional exports. In this case, we have the following conditions:
      • "require" specifies the entry point for CommonJS modules
      • "default" specifies the entry point that is used in all other cases
  • "module" specifies the entry point for legacy tools.

Node.js supports the following conditions:

  • "require": The importer must be a CommonJS modules
  • "node": The target platform must be Node.js
  • "default": The catch-all case (similar to JavaScript switch statements)

Other conditions must be defined and supported by other target platforms and tools. Examples include: "browser", "electron", "deno", "react-native",

Requiring from CommonJS modules:

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

Importing from ESM modules:

import {x} from 'mypkg';

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: "type": "commonjs" 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';

Entry points:

  • ESM Node.js: Due to the filename extension .mjs, Node.js interprets the file pointed to by "default" as an ES module.
  • Pre-ESM Node.js: uses the CommonJS entry point (see previous subsection).
  • Modern bundlers can use the same entry point as ESM Node.js.
  • Older bundlers use the entry point specified via "module".

Variant: avoiding .mjs  

Browsers don’t care about filename extensions, only about content types, but some tools may still have trouble with the filename extension .mjs. If we want to avoid those, we can switch to the following approach.

Files in the package:

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": {
    ".": {
      "require":  "./commonjs/entry.js",
      "default": "./esm/entry.js"
    }
  },
  "module": "./esm/entry.js",
  ···
}

Note:

  • "type": "module" means that .js files are interpreted as ESM by default.

mypkg/commonjs/package.json:

{
  "type": "commonjs",
}

Note:

  • This file overrides our default. Inside the directory ``mypkg/commonjs/, .js` files are now interpreted as CommonJS modules.

Option 2: bare-import CommonJS, deep-import ESM (maximum backward compatibility)  

Scenario: We want to make it easy for clients of our package to upgrade from an existing CommonJS-only version to a hybrid version. We also want to use .js for ES modules, to be maximally compatible with existing tools.

This hybrid package 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 we normally want .js files to be interpreted as ES modules.
  • "exports" defines a package export that enables the module specifier 'mypkg/esm' for the ES module.

mypkg/commonjs/package.json:

{
  "type": "commonjs"
}

Note:

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

Importing from CommonJS:

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

Importing from ESM:

import {x} from 'mypkg/esm';

Option 3: bare-import ESM, deep-import CommonJS with 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 4: bare-import ESM, deep-import CommonJS with .mjs and .cjs  

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 3:

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 (.js instead of .cjs)  

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.

Acknowledgements  

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

Further reading