What does it take to support Node.js ESM?
TheGuildBot
Posted on August 12, 2021
This article was published on Thursday, August 12, 2021 by Pablo Sรกez @ The Guild Blog
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:
```json filename="package.json"
{
"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
```jsonc
{
// 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:
```json filename="bar/package.json"
{
"name": "bar",
"exports": {
"./": {
"require": "./dist/.js",
"import": "./dist/*.mjs"
}
}
}
```ts
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
```json filename="package.json"
{
"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](https://pnpm.io/package_json#publishconfigdirectory) and
[lerna publish as well](https://github.com/lerna/lerna/tree/main/commands/publish#publishconfigdirectory).
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.
```json filename="dist/package.json"
{
"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.
Posted on August 12, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.