What does it take to support Node.js ESM?

Pablo Sáez

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:

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

{
  // 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:

  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')

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:

  • 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')
  • 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'

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:

bar/package.json
{
  "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

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 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.

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.

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.