Mastodon

To Love and to Learn (Gil Tayar's Blog)

Using ES Modules (ESM) in Node.js: A Practical Guide (Part 2)

ESM Logo

(Hey, if you want to come work with me at Roundforest, and try out ESM on Node.js, feel free to find me on LinkedIn or on Twitter (@giltayar))

This is part 2 of the guide for using ES modules in Node.js.

  1. The simplest Node.js ESM project
  2. Using the .js extension for ESM
  1. The exports field
  2. Multiple exports
  3. Dual-mode libraries
  1. Tooling
  2. TypeScript

This guide comes with a monorepo that has 7 directories, each directory being a package that demonstrates the above sections. You can find the monorepo here.

On to the next section, where we’ll start looking at a new feature in Node.js: exports.

The exports field #

Companion code: https://github.com/giltayar/jsm-in-nodejs-guide/tree/main/03-exports

The exports field is a new field in package.json which controls what entry points a package has. Let’s look at the simplest case.

The exports field #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/03-exports/package.json

{
// package.json
"name": "03-exports",
"type": "module",
"main": "src/main.js",
"exports": {
".": "./src/main.js"
}

The entry point to the package, which is the file that loads when your import or require a package, is src/main.js (just like in the previous parts of the guide), and is usually defined by the field main.

While you can use main to define the entry point, the correct ESM way to define the package entry point is a new field: exports. Note that in exports, the path MUST start with a “.”. This makes sense, as it is the same path you would use to import the file. If you’re using main, you don’t need the “.”, but the exports field mandates it.

Using exports, you define the entry point (in the above case “.”), and what that entry point points to: ./src/main.js. It looks very verbose, but we’ll see why this syntax is this way shortly.

Why use exports if main is enough? Looks the same, right? Well, it’s close, but if you use exports, then no import can deep-link into the package, i.e. if the package was published, another package could import it by using

import {banner} from "01-simplest-mjs"

But wouldn’t be able to do this (they’ll get an error)

import {banner} from "01-simplest-mjs/src/main.mjs"

An interesting thing to note about exports, is that this is actually not a ESM feature. It is a Node.js feature and is also available for CJS! So exports works in CJS packages, and if exports is defined in a CJS package, then no deep linking is allowed in CJS too.

Self-referencing the package #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/03-exports/test/tryout.js

Another importan benefit can be seen in test/tryout.js.

// test/tryout.js
import {banner} from '03-exports'

This is code from the existing package, yet it can self-reference itself using the name of the package, instead of using ../src/main.js.

Gotcha: unfortunately, none of the toolings (eslint, typescript, etc) understand that this is is a self-reference that is allowed in Node.js and flag this as an error. But you can always disable the error on this specific line, as it most definitely works in runtime.

So exports gives us a nice way of “hiding” our inner modules and not exposing them outside the package. This is great, but has a nasty side-effect: some tools need access to all the packages package.json, and using exports bars them from accessing it. To enable them to access the package.json, we add another line to the package.json “exports”:

  // package.json
"exports": {
".": "./src/main.js",
"./package.json": "./package.json"
}

The second line ("./package.json": "./package.json") tells Node.js that using import '03-exports/package.json' or require('03-exports/package.json') is OK.

Gotcha: you can start using exports, but please do include the ./package.json escape hatch and also continue to include main as some tooling (e.g. TypeScript) does not yet understand export.

Multiple exports #

Companion code: https://github.com/giltayar/jsm-in-nodejs-guide/tree/main/04-multiple-exports

We’ve already seen the exports field in package.json. Let’s see what else it can do:

Multiple entry points #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/04-multiple-exports/package.json

{
// package.json
"name": "04-multiple-exports",
"exports": {
".": "./src/main.js",
"./red": "./src/main-red.js",
"./blue": "./src/main-blue.js",
"./package.json": "./package.json"
}
}

In the above, we can see four entry points: the main one (04-mutiple-exports), the red and blue ones (04-multiple-exports/red and 04-multiple-exports/blue), and the “package.json” one (04-multiple-exports/package.json). This is a nice way to define multiple entry points to a package, and as we’ve already seen, still “hides” the other implementation modules from the outside.

Let’s see how we test this using the “self-referencing” feature of ESM:

Self-referencing the multiple entry points #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/04-multiple-exports/test/tryout.js

// test/trout.js
import {banner as whiteBanner} from "04-multiple-exports"
import {banner as redBanner} from "04-multiple-exports/red"
import {banner as blueBanner} from "04-multiple-exports/blue"

As you can see, we can self-reference the entry points in the module itself, and thus are able to test the module entry points.

Gotcha: (already mentioned above, repeated here for reference) unfortunately, none of the toolings (eslint, typescript, etc) understand that this is is a self-reference that is allowed in Node.js and flag this as an error. But you can always disable the error on this specific line, as it most definitely works in runtime.

Dual-mode libraries #

Companion code: https://github.com/giltayar/jsm-in-nodejs-guide/tree/main/05-dual-mode-library

Up to now, we created ESM code that was exported as a ESM package. So if another ESM package “npm install”-ed this one, then it could use it without a problem (using “import”). But if a CJS package “npm install”-ed it, it would be difficult to use, because we are not allowed to “require” a ESM package, and the only way to use it would be using “await import(...)”, which is problematic because it is an async operation, which can only be used in an async function.

Why does this limitation exist? Because CJS is a synchronous module system, and ESM is asyncronous, the only way to import ESM from CJS is using an asynchronous operation, “await import(...)”.

But what if we could create a module that can be both import-ed and require-ed? If it is “import”-ed, it uses ESM code, and if it is “require”-ed, it uses CJS code.

We can! And again, we use the incredible exports field, and its ability to do “conditional exports”. Let’s look at the package.json of this package.

Conditional exports in package.json #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/05-dual-mode-library/package.json

// package.json
{
"name": "05-dual-mode-library",
"exports": {
".": {
"require": "./lib/main.cjs",
"import": "./src/main.js"
}
}
}

The main entry point (.), instead of being a path to the entry point module, is an object that has two “conditions”: require and import, and it’s those conditions that have paths to entry point modules. The conditions define when each entry point will be used. If you’re require-ing the package, Node.js uses ./lib/main.cjs, and when you’re import-ing it, Node.js uses ./src/main.js.

Perfect for our needs! We just point the two entry points to different implementations: one written for ESM and one for CJS.

Just one small detail… Are we now going to write two implementations of our package, one for CJS (using require everywhere) and one for ESM (using import everywhere)? That’s asking a bit too much, I think.

One simple way out of this conundrum, is to write the package using CJS, and then write a small ESM wrapper that “require”-s the code end “export”-s it as ESM. That’s a great solution, and works well for existing packages that are CJS: they can wrap their code with a ESM wrapper, define the conditions like above, and voila: they’re dual-mode! But if we’re using ESM, this is not an option for us. So is there a way to continue writing ESM code, and yet still have a parallel CJS entry point?

Yes, there is. We do what our ancestors did: we transpile. 😎 For this, I’m going to use Rollup. Rollup is great because it understands ESM, understands CJS, and can convert from one to another, so we don’t have to do a lot to get Rollup to take all our code and just transpile it from ESM to CJS.

Dev Dependencies needed for transpiling #

We’ll need rollup, so we npm install it. We’ll also npm install the cpr package, to use as a cross platform way to copy our non-js assets (in our case, it’s the text.txt file used by the code).

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/05-dual-mode-library/package.json

  // package.json
"devDependencies": {
"cpr": "^3.0.1",
"rollup": "^2.38.1"
},

Now let’s see how we configure Rollup to transpile our code.

Transpiling ESM to CJS using Rollup #

Remember, the code is in src/*.js and we want to transpile to CJS code in lib/*.js.

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/05-dual-mode-library/rollup.config.mjs

// rollup.config.mjs
const srcFiles = await fs.promises.readdir(new URL('./src', import.meta.url))

export default {
input: srcFiles
.filter((file) => file.endsWith('.js'))
.map((x) => `src/${x}`),
output: {
dir: 'lib',
format: 'cjs',
entryFileNames: '[name].cjs',
preserveModules: true,
}
}

Gotcha: theoretically, we should be able to name the rollup config file rollup.config.js, because it uses ESM. but Rollup assumes that .js is CJS, and so forces us to name the file rollup.config.mjs.

Let’s deconstruct this code. First, the input, which is the list of .js files in src.

Now for the output: The output dir is lib, and we want to format it as cjs. This makes sense.

But why do we want the output filenames to end with .cjs? Because if we ended them with .js, they will be treated as ESM files. So we use the .cjs extension to force Node.js to treat them as CJS files.

And what is that preserveModules: true? This forcs Rollup to create a module for each module in the input, instead of trying to chunk them together.

And that’s it, we just need to run Rollup, and not to forget to copy the other assets also to lib.

Build script #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/05-dual-mode-library/package.json

  // package.json
"scripts": {
"build": "rollup -c && cpr src/text.txt lib/ --overwrite",
},

Testing the code #

Code: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/05-dual-mode-library/test/tryout.js

And: https://github.com/giltayar/jsm-in-nodejs-guide/blob/main/05-dual-mode-library/test/tryout.cjs

To test the code, we first npm run build to generate the CJS code, and then run test/tryout.js, that executes

// test/tryout.js
import {banner} from '05-dual-mode-library'
//...

And then we also run test/tryout.cjs, that executes

// test/tryout.cjs
const {banner} = require('05-dual-mode-library')
//...

Each one will find the correct entry point using the conditional export, and all will be well.

OK, cool. We’re done with the basics of ESM in Node.js. Now for the tooling: using things like ESLint, test runners, and mocks. You can find that in the next part, here.