What does it take to support Node.js ESM?
ECMAScript modules, also known as ESM, is the official standard format to package JavaScript, and fortunately Node.js supports it 🎉.
But if you have been in the Node.js Ecosystem for some time and developing libraries, you have probably encountered the fact that ESM compatibility has been a struggle, behind experimental flags and/or broken for practical usage.
Very few libraries actually supported it officially, but since Node.js v12.20.0 (2020-11-24) and
v14.13.0 (2020-09-29) the latest and finally stable version of package.exports
is available,
and since support for Node.js v10.x is dropped, everything should be fine and supporting ESM
shouldn’t be that hard.
After working on migrating all The Guild libraries, for example GraphQL Code Generator or the recently released Envelop, and contributing in other important libraries in the ecosystem, like graphql-js, I felt like sharing this experience is really valuable, and the current state of ESM in the Node.js Ecosystem as a whole needs some extra care from everyone.
This post is intended to work as a guide to support both CommonJS and ESM and will be updated
accordingly in the future as needed, and one key feature to be able to make this happens, is the
package.json
exports
field.
exports
Field
The official Node.js documentation about it is available here, but the most interesting section is Conditional exports, which enables libraries to support both CommonJS and ESM:
{
"name": "foo",
"exports": {
"require": "./main.js",
"import": "./main.mjs"
}
}
This field basically tells Node.js what file to use when importing/requiring the package.
But very often you will encounter the situation that a library can (and should, in my opinion) ship the library keeping their file structure, which allows for the library user to import/require only the modules they need for their application, or simply for the fact that a library can have more than a single entry-point.
For the reason just mentioned, the standard package.json#exports
should look something like this
(even for single entry-point libraries, it won’t hurt in any way):
Assuming that the build/compilation/transpilation is outputted into the “dist” folder
{
// package.json
"name": "foo",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./*": {
"require": "./dist/*.js",
"import": "./dist/*.mjs"
}
}
}
To specify specific paths for deep imports, you can specify them:
{
"exports": {
// ...
"./utils": {
"require": "./dist/utils.js",
"import": "./dist/utils.mjs"
}
}
}
If you don’t want to break backward compatibility on import/require with the explicit .js
, the
solution is to add the extension in the export:
{
"exports": {
// ...
"./utils": {
"require": "./dist/utils.js",
"import": "./dist/utils.mjs"
},
"./utils.js": {
"require": "./dist/utils.js",
"import": "./dist/utils.mjs"
}
}
}
Using the .mjs
Extension
To add support ESM for Node.js, you have two alternatives:
- build your library into ESM Compatible modules with the extension
.mjs
, and keep the CommonJS version with the standard.js
extension - build your library into ESM Compatible modules with the extension
.js
, set"type": "module"
, and the CommonJS version of your modules with the.cjs
extension.
Clearly using the .mjs
extension is the cleaner solution, and everything should work just fine.
ESM Compatible
This section assumes that your library is written in TypeScript or has at least has a transpilation process, if your library is targeting the browser and/or React.js, it most likely already does.
Building for a library to be compatible with ESM might not be as straight-forward as we would like,
and it’s for the simple fact that in the pure ESM world, require
doesn’t exists, as simple as
that, You will need to refactor any require
into import
.
Changing require
If you have a top-level require
, changing it to ESM should be straight-forward:
from
const foo = require('foo')
to
import foo from 'foo'
But if you are dynamically calling require inside of functions, you will need to do some refactoring to be able to handle async imports:
from
function getFoo() {
const { bar } = require('foo')
return bar
}
to
async function getFoo() {
const { bar } = await import('foo')
return bar
}
What about __dirname
, require.resolve
, require.cache
?
This is when it gets complicated, citing the Node.js documentation:
This is kinda obvious, you should use import
and export
The only workaround to have an isomorphic __dirname
or __filename
to be used for both “cjs” and
“esm” without using build-time tools like
@rollup/plugin-replace or
esbuild “define” would be using a library like
filedirname that does a trick inspecting error stacks, it’s
clearly not the cleanest solution.
The workaround alongside with createRequire
should like this
import { createRequire } from 'node:module'
import filedirname from 'filedirname'
const [filename] = filedirname()
const require_isomorphic = createRequire(filename)
require_isomorphic('foo')
require.resolve
and require.cache
are not available in the ESM world, and if you are not able to
do the refactor to not use them, you could use
createRequire, but keep
in mind that the cache and file resolution is not the same as while using import
in ESM.
Deep Import of node_modules
Packages
Part of the ESM Specification is that you have to specify the extension in explicit scripts imports,
which means when you are importing a specific JavaScript file from a node_modules package you have
to specify the .js
extension, otherwise all the users will get
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
This won’t work in ESM
import { foo } from 'foo/lib/main'
But this will
import { foo } from 'foo/lib/main.js'
BUT there is a big exception to this, which is the node_modules package you are importing
uses the exports
package.json
field, because generally the exports
field will have
to extension in the alias itself, and if you specify the extension on those packages, it will result
in a double extension:
{
"name": "bar",
"exports": {
"./*": {
"require": "./dist/*.js",
"import": "./dist/*.mjs"
}
}
}
import { bar } from 'bar/main.js'
That will translate into node_modules/bar/main.js.js
in CommonJS and
node_modules/bar/main.js.mjs
in ESM.
Can We Test If Everything Is Actually ESM Compatible?
The best solution for this is to have ESM examples in a monorepo testing firsthand if everything with the logic included doesn’t break, using tools that output both CommonJS & ESM like tsup might become very handy, but that might not be straightforward, especially for big projects.
There is a relatively small but effective way of automated testing for all the top-level imports in
ESM, you can have an ESM script that imports every .mjs
file of your project, it will quickly
scan, importing everything, and if nothing breaks, you are good to go 👍, here is a small example of
a script that does this, and it’s currently used in some projects that support ESM
https://gist.github.com/PabloSzx/6f9a34a677e27d2ee3e4826d02490083.
TypeScript
In regard to TypeScript supporting ESM, it divides into two subjects:
Support for exports
Until this issue TypeScript#33069 is closed, TypeScript doesn’t have complete support for it, fortunately, there are 2 workarounds:
- Using
typesVersions
The original usage for this TypeScript feature was not for this purpose, but it works, and it’s a fine workaround until TypeScript actually supports it
{
"typesVersions": {
"*": {
"dist/index.d.ts": ["dist/index.d.ts"],
"*": ["dist/*", "dist/*/index.d.ts"]
}
}
}
- Publishing a modified version of the package
This method requires tooling and/or support from the package manager. For example, using the
package.json field publishConfig.directory
,
pnpm supports it and
lerna publish as well.
This allows you to publish a modified version of the package that can contain a modified version of
the exports
, following the types with the file structure in the root, and TypeScript will
understand it without needing to specify anything special in the package.json for it to work.
{
"exports": {
"./*": {
"require": "./*.js",
"import": "./*.mjs"
},
".": {
"require": "./index.js",
"import": "./index.mjs"
}
}
}
In The Guild we use this method using tooling that creates the temporary package.json automatically. See bob-the-bundler & bob-esbuild
Support for .mjs
Output
Currently, the TypeScript compiler can’t output .mjs
, Check the issue
TypeScript#18442.
There are workarounds, but nothing actually works in 100% of the possible use-cases (see for example, ts-jest issue), and for that reason, we recommend tooling that enables this type of building without needing any workaround, usually using Rollup and/or esbuild.
ESM Needs Our Attention
There are still some rough edges while supporting ESM, this guide shows only some of them, but now it’s time to rip the bandaid off.
I can mention a very famous contributor of the Node.js Ecosystem
sindresorhus who has a very strong stance in ESM. His Blog post
Get Ready For ESM
and a
very common GitHub Gist
nowadays in a lot of very important libraries he maintains.
But personally, I don’t think only supporting ESM and killing CommonJS should be the norm, both standards can live together, there is already a big ecosystem behind CommonJS, and we shouldn’t ignore it.
Join our newsletter
Want to hear from us when there's something new?
Sign up and stay up to date!
*By subscribing, you agree with Beehiiv’s Terms of Service and Privacy Policy.