Getting Started with (and Surviving) Node.js ESM

9 November 2021

Let's begin with a basic JavaScript question—how do you import code from one file to another?

For browser and front-end application authors, the modern answer has been ECMAScript Modules ("ESM") features like import and export for quite some time now. For Node.js developers, however, it's pretty recent news with official ESM support landing in May, 2020.

The Node.js ecosystem has already started shifting towards ESM, and it's important to consider what lies in store for application and library authors. In this post, we'll look at the emergence of ESM in Node.js and learn some of the challenges and opportunities that lie ahead for Node.js developers with topics including:

  • What does an ESM package look like?
  • What does the new package.json:exports field do?
  • How do I import CommonJS in ESM?
  • How do I import ESM in CommonJS?

Fig. 1 - Importing overview Fig. 1 - Resolving a simple import like "colors" to a file like "index.js" can become pretty complicated!

The Rise of ESM in Node.js

Let's first clarify some concepts and terminology.

CommonJS: The existing scheme for importing code in Node.js is CommonJS (aka "CJS"), usually has a dependency like:

// colors/index.cjs (the `.cjs` suffix forces CommonJS)
module.exports = {
  red: "red",
  blue: "blue"
};

that is then imported with something like:

// my-app/file.cjs
// Can call anywhere (synchronously), use variables, etc.
const colors = require("colors");         // Obj w/ all export fields
const { red, blue } = require("colors");  // Select fields from export obj

ESM: The new kid on the street, ECMAScript Modules ("ESM"), offers a slightly different syntax, with some additional constraints and rules. Let's start with a dependency:

// colors/index.mjs (the `.mjs` suffix forces ESM)
export const red = "red";
export const blue = "blue";
export default { green: "green" };

And then look to consumption in application code. Even with just these four examples, it's interesting to see the variety of ways the same code can be consumed in ESM.

// my-app/file.mjs
// Static: top of file, only string imports
import colors from "colors";          // _Only_ `default` export (`{ green }`)
import { red, blue } from "colors";   // Other named exports

// Dynamic: can call anywhere, but _must_ be asynchronous
const colors = await import("colors");         // This is an obj like CJS!
// Object shape: `colors = { red, blue, default: { green } };`
const { red, blue } = await import("colors");  // Destructure fields like CJS

CommonJS has been the import scheme since practically the beginning of Node.js, but suffers from various drawbacks (such as being difficult to introspect, etc.). As ESM emerged as the winning browser specification for code imports, work began to bring the same scheme to Node.js. In May, 2020, Node.js v12.17.0 made ESM support available to all Node.js applications (without experimental flags).

The ecosystem latched on to the unflagged support, and as Node.js v10 neared its end-of-life date in April, 2021, some prominent library authors began discussing migrating their packages to only support ESM. Fast forward to now and we're starting to see a growing number of packages convert to supporting only ESM. Chances are, if you're not familiar with ESM in Node.js or haven't encountered a package that only supports it, you probably will in the not-too-distant future.

So let's chat about what's in store and what you might need to know!

Some Node.js ESM Tips and Tricks

In support of the discussion sections below, we have published a GitHub repository with several related examples, along with an execution tool to run everything in various Node.js versions and modes. We encourage you to run the samples, and then tweak and experiment with more Node.js ESM options to see how all of the various tricky details work!

What makes an ESM package?

For starters, let's look at the ways that your application or a package you consume can get Node.js into ESM "mode". The main ways are as follows:

  • The package.json file contains a field "type": "module". This will make Node.js interpret all files in the package as ESM files.
  • An imported file name ends with .mjs.

And that's mostly it for a single package! If you are not doing one of the above, then Node.js will likely assume at runtime that you want to be in CommonJS mode.

But things get complicated quickly. The above rules just hold within a single package or application. Going across a package boundary (e.g., importing a dependency) can make Node.js switch modes back and forth between CommonJS and ESM—and, all many times within the same ultimate parent application! Additionally, which files will be imported at runtime can be configured and changed in a multitude of ways, most of which we won't even discuss in this post. So, let's get ready for some complexity and dive into our next section on exports...

Specifying exports with package.json:exports

Arguably the biggest feature and pain with Node.js' ESM support is not ESM itself, but rather the new exports field in package.json, which applies to both CommonJS and ESM code.

This field controls and changes a lot of existing behavior as to how Node.js resolves a file when you do something like import { red } from "colors" or require("colors/sub-file"). And, just to spice things up, even for just ESM, the exports field doesn't match with how browsers implement similar features.

Let's see what's in store for us!

Resolving import paths

Let's start with an example—say we want to import a package named "colors". What file will we actually resolve to on disk? Let's assume that we have the following files:

  • one.js (CJS)
  • two.cjs (CJS)
  • three.mjs (ESM)

Here's a sample package.json with comments as to which file is resolved for matching versions of Node.js and import types:

"main": "./one.js",          // Node CJS <  12.17.0
"exports": {
  ".": {
    "require": "./two.cjs"   // Node CJS >= 12.17.0
    "import": "./three.mjs", // Node ESM >= 12.17.0
  }
}

In the above example, Node.js prior to v12.17.0 will only use the main field to resolve files, so one.js will be resolved. In modern Node.js versions, the exports field is examined if present, and "." matches the root import (aka "colors" with no path), and then the "mode" is matched in the fields with "require" for CommonJS and "import" for ESM. The different roots scenario in our examples repository illustrates this situation if you want to run some code for yourself and see the differences.

This is just the tip of the exports complexity iceberg. There are "default" fields if "require" or "import" aren't provided. You can even craft user-defined custom fields that you pass to the Node.js CLI to alter how runtime loads files!

Well, that's complicated! For a project maintainer, looking for a short list of options, consider the following:

  • Just do CommonJS the old way and provide package.json:main.
  • If you want to provide both ESM and CommonJS, then you're probably best off having your real source be ESM, transpile to CommonJS output and use the exports + "." example above to split between "import" for ESM and "require" for CommonJS. And then read the next section on path encapsulation for some additional gotchas. 😉

Path encapsulation

The other pain point for exports is that once you specify the field, you must manually expose any other paths that you want to allow downstream consumers to import. For example, many Node.js applications and libraries will import the package.json file of a dependency. But in our previous example you would not be able to do something like: import "colors/package.json"!

Fig. 2 - Path encapsulation Fig. 2 - Imported strings must match a field in exports to translate to an actual file. E.g., even if a.js exists in the library, if exports doesn't expose it, there is no way to reach it.

Thus, a very important tip for Node.js library authors using exports is to always specify all import paths your downstream consumers need. In our example, we'd want to add something like:

"exports": {
  // ...
  "package.json": "./package.json"
}

Fortunately, the exports scheme supports wildcards and many other options to make exposing lots of subpath resources in your libraries easier.

Wow, that was complicated! Well, the bad news is that there are tons more options, including the complementary package.json:imports feature that is an entirely different can of worms. For the curious, we recommend heading on over to the Node.js docs and seeing just how deep the exports rabbit hole goes.

Importing CommonJS in ESM

Now let's take a look at actually doing imports in our code. As you'd expect, importing ESM from ESM and CommonJS from CommonJS works just fine. So what happens when we import one type from the other?

Let's look at the easy scenario first—importing CommonJS code into ESM.

The good news is that you can mostly just import a CommonJS dependency. The only real catch is that you can only do a default object import and not named imports.

For example, a dependency like:

// node_modules/colors/index.cjs
module.exports = {
  red: "red",
  blue: "blue"
};

can be imported as:

// my-app.mjs
import colors from "colors";
// WON'T WORK: `import { red, blue } from "colors";`

// CAVEAT: We can't do named import, but can expand later.
const { red, blue } = colors;

Other than named import caveat, that's pretty much it!

Due to this ease, library authors may want to consider a wrapper pattern (suggested in the Node.js documentation) that writes the substantive files in CommonJS, then imports the CommonJS files into wrapper ESM files that do individual named exports.

Importing ESM in CommonJS

Now, on to the bad news—importing ESM files into CommonJS is awkward and painful.

To import an ESM file into CommonJS, all of the following must be observed:

  • You must be on Node.js v12.17.0+ (OK, well this applies to anything ESM-related 😉); and,
  • Use a dynamic import() call.

If you would like to see this in action in CommonJS (and ESM), check out our dynamic import scenario in the examples repository.

So... what's the bad part? Let's start with an example CommonJS dependency import:

// Yay! Easy!
const { red } = require("colors"); // colors is CJS

module.exports = {
  logRed: () => console.log(red)
};

Not too tricky. Now, let's consider if the colors library switches over to only providing ESM imports. Ideally, we'd just refactor to something like the following to keep the import in the same place and not change our API:

// DOES NOT ACTUALLY WORK!!!
const { red } = await import("colors"); // colors is now ESM

module.exports = {
  logRed: () => console.log(red)
};

... but it doesn't work because you cannot use top-level await in any CommonJS code.

So we're left with a tough dilemma. If we want to upgrade, we can only get our import() asynchronously, and in this case means we'd have to change our API function logRed to be asynchronous as well, with something like:

module.exports = {
  // Breaking API change: logRed is now async.
  logRed: async () => {
    // Dynamic import() works in function awaits or promises
    // NOTE: Could memoize the `import()` to cache after 1st call.
    const { red } = await import("colors");
    console.log(red);
  }
};

The broader impact here is that CommonJS applications and libraries can only easily consume ESM dependencies if all functionality is only called upstream within asynchronous functionality.

Such a case exists in one of our tools, trace-pkg, where an upstream dependency (globby) has converted to ESM-only. We have an open branch showing how the CommonJS code can consume the changed dependency without affecting the library's API. But, unfortunately, some of our libraries are grappling with breaking API changes just to support the latest, ESM-only versions of some of their dependencies.

Practical Takeaways and Next Steps

Hopefully this post gives you a few hints of what's to come now that the Node.js community is moving towards at least supporting—and potentially only using—ESM and modern package.json fields like exports. Although we only touched on just a few scenarios, we'll leave you with some tips to hopefully make your impending ESM journey a bit smoother:

  • If you are an application author, and your Node.js app is written as ESM, then everything is probably going to be just fine! You can easily import both CommonJS and ESM dependencies.
  • If you are a library author and want to allow your downstream consumers to still use CommonJS...
    • It is easiest to just stick with CommonJS entirely.
    • If you want to provide both CommonJS and ESM files, then consider options like (1) writing ESM code and transpiling to CommonJS for publishing, or (2) using a wrapper pattern if you don't have a build step.
  • If you do use ESM, please consider your exports declarations carefully. In particular, make sure to expose any sub-paths your consumers may expect to be able to import directly.

We encourage you to read up on the entire Node.js packages documentation page as your starting point into more advanced ESM. Some other good articles that get into more practical details include:

Finally, make sure to check out our ESM examples repository for further experiments in a lot of different Node.js runtimes. Good luck and hopefully your path to Node.js ESM (forced or elective) goes smoothly!

Custom illustrations by Sophie Calhoun

Related Posts

Check out more of Ryan's blog posts