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.
Warning: This area is still under active development, so some things may still change.
Table of contents:
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 entry point.
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.
Native support for ES modules in Node.js:
--experimental-modules
Terminology:
With ESM Node.js, we have new options for implementing hybrid packages.
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
:
···
"main": "./commonjs/entry.js",
"exports": {
"./esm": "./esm/entry.js"
},
"module": "./esm/entry.js",
"type": "module",
···
Notes:
"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 deep import paths to import ES modules).
"type": "module"
means that .js
files are interpreted as ESM. We want ESM to be the default, not CommonJS.
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.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:
mypkg/commonjs/package.json
ensures that .js
files are interpreted as CommonJS modules..js
as CommonJS.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:
"type": "module"
in mypkg/package.json
, Node.js interprets the .js
file pointed to by "./esm"
as an ES module."module"
.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
:
···
"main": "./esm/entry.js",
"module": "./esm/entry.js",
"type": "module",
···
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
.
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 need less configuration and can use the filename extensions .mjs
(ESM) and .cjs
(CommonJS) to indicate the module formats contained in files. 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
:
···
"main": "./esm/entry.mjs",
···
Note that we didn’t need the following two properties because we are not using .js
(which is configured via "type"
) and because we don’t have to support older bundlers (which need "module"
).
"module": "./esm/entry.js",
"type": "module",
ESM and CommonJS are used the same way as in option 2:
import {x} from 'mypkg';
const {x} = require('mypkg/commonjs');
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 because .js
is interpreted as CommonJS if there is no "type"
(as in this case) or if there is "type": "commonjs"
.
The Node.js docs suggest two approaches for hybrid packages:
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:
.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.
Among others, the following people provided important input for this blog post: