DEV Community

Cover image for What does it take to support Node.js ESM?
TheGuildBot for The Guild

Posted on • Edited on • Originally published at the-guild.dev

What does it take to support Node.js ESM?

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

To specify specific paths for deep imports, you can specify them:

{
  "exports": {
    // ...
    "./utils": {
      "require": "./dist/utils.js",
      "import": "./dist/utils.mjs"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the .mjs Extension

To add support ESM for Node.js, you have two alternatives:

  1. build your library into ESM Compatible modules with the extension .mjs, and keep the CommonJS version with the standard .js extension
  2. 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')
Enter fullscreen mode Exit fullscreen mode

to

import foo from 'foo'
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

to

async function getFoo() {
  const { bar } = await import('foo')

  return bar
}
Enter fullscreen mode Exit fullscreen mode

What about __dirname, require.resolve, require.cache?

This is when it gets complicated,
citing the Node.js documentation:

  • image.png

This is kinda obvious, you should use import and export

  • image.png

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')
Enter fullscreen mode Exit fullscreen mode
  • image.png

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'
Enter fullscreen mode Exit fullscreen mode

But this will

import { foo } from 'foo/lib/main.js'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
lil5 profile image
Lucian I. Last • Edited

I disagree, the big hurdle is exactly because there are two competing systems where one is used more the other but is not the default. This is a sign that CommonJS is at deaths gate.

Let’s move on