Update 2019-11-22:
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.
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.
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. 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).
"."
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';
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:
"type": "commonjs"
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';
Entry points:
.mjs
, Node.js interprets the file pointed to by "default"
as an ES module."module"
..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:
,
.js` files are now interpreted as CommonJS modules.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';
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
.
.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.
.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');
.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
.
Among others, the following people provided important input for this blog post: