← Back to Releasing JavaScript
Making the switch to native ESM packages
Status: work in progress.
Using ES modules natively in Node
Node.js versions 12 and later support ES modules natively. To switch your whole package to ES modules, include type: module
in your package.json
:
{
"type": "module"
}
This instructs Node.js to consider all the .js
files in your project folder to be ES modules.
Publishing ES modules to npm
There are several approaches here. You could point your main
file to the ESM entry point, or use the newer exports
.
Using the exports
field
Publishing responsibly. Switching to using the
exports
field, or changing the exports (with the exception of adding new exports), is a breaking change and entails a major version bump in semver.
When using the exports
field, you restrict what can be imported from the project.
It's good practice to include
package.json
as one of the exports, as some build tools depend on being able to read it.
Converting CJS modules to ESM
You can consult the documentation for the differences between ESM and CJS modules. Here are some common rewriting tasks:
Add the file extension to relative import
specifiers
Bundlers allow you to import
modules without an extension, like in require()
. To use native ESM, you'll need to add explicit file extensions. You can write a jscodeshift
transform to add it as described in Use code to explore and change JavaScript files.
// CJS:
const toPrecision = require('./util/to-precision');
// ESM (Most bundlers):
import toPrecision from './util/to-precision';
// ESM (Native):
import toPrecision from './util/to-precision.js';
Use import.meta.url
instead of __filename
, __dirname
Methods in the node:fs
module accept URL
objects instead of paths:
// CJS:
const fs = require('node:fs/promises');
const path = require('node:path');
const content = await fs.readFile(path.join(__dirname, './some.txt'), 'utf8');
// ESM:
import fs from 'node:fs/promises';
const content = await fs.readFile(new URL('./some.txt', import.meta.url), 'utf8');
Node.js 20.11 also added direct equivalents to __dirname
and __filename
as import.meta.dirname
and import.meta.filename
respectively.
Use import attributes to read JSON files
Starting with Node.js 18.20, you can use import attributes to import JSON files directly:
import content from './some.json' with { type: 'json' };
For earlier versions of Node.js, you must read JSON files with fs
:
import fs from 'node:fs/promises';
const content = JSON.parse(
await fs.readFile(new URL('./some.json', import.meta.url))
);
Providing fallbacks
ESM + CJS dual package
There are some disadvantages to publishing a dual, ESM + CJS, package.
The dual package hazard
The export from your ESM module is not the same as the export from the CJS module. If your library is both import
-ed and require()
-d in the same codebase (either directly, or undirectly via a dependency), it can cause a number of effects, collectively named dual package hazard:
- code is imported, run, and bundled twice — once for the ESM version, and once for the CJS version.
- if the library has state (e.g. global configuration), this state will be separate in the ESM and CJS versions.
UMD bundle
TBD. Some online tools, such as Observable, need an UMD bundle.
Further reading
- Node.js documentation on: ESM, Packages
- Pure ESM package by Sindre Sorhus
- Node Modules at War: Why CommonJS and ES Modules Can’t Get Along by Dan Fabulich