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

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

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

In this third and final part we see how to integrate tools like ESlint, Mocha/Chai, Testdouble, and TypeScript.

Tooling #

Companion code:

All the above directories were “toy” directories. 06-tooling is a more complete package, with all the modern necessities needed for JavaScript development. Included in the package is support for:

Gotcha: as I said in the introduction, all major test runners today support ESM, except for Jest that only has experimental support.

Gotcha: the only mocking library that supports mocking ESM modules is currently TestDouble.

Let’s start exploring the package, tool by tool. We’ll start with the most important of them, ESLint.

ESlint configuration for Node.js ESM #

ESlint already supports ESM well because a lot of code is already using ESM via Babel or TypeScript. But there is one aspect it doesn’t yet support, and that is top-level await. If you’re not using top-level await, then just go ahead and use whatever ESLint configuration you already have. Just don’t forget to use sourceType: "module".


// .eslintrc.json
"parserOptions": {
"sourceType": "module"

But if you are using top-level await (and I heartily recommend it!), then ESLint will complain that it cannot parse it. The solution to this, at least until ESLint supports it, is to use the babel parser with the correct plugin:

// .eslintrc.json
"parser": "@babel/eslint-parser",
"parserOptions": {
"sourceType": "module"

But that is not enough. You need to install the babel parser (with babel core), and the babel plugin that supports top-level await:

npm install --save-dev @babel/eslint-parser @babel/core @babel/plugin-syntax-top-level-await

And to create a small .babelrc file that points to the plugin.


// .babelrc.json
"plugins": ["@babel/plugin-syntax-top-level-await"]

That’s it! Now when we run eslint, it will use the babel parser, which will use the babel plugin that knows how to parse top-level await. And all is well.

Well, almost. There is a much used plugin for ESlint, the Node.js ESLint plugin that supports linting Node code. And it supports ESM out of the box! And pretty well. Unfortunately, it currently (February 2021) has a bug where it thinks Node.js doesn’t support await import(...), and the only workaround I could find was to disable the error in the eslint configuration:

// .eslintrc.json
"rules": {
"node/no-unsupported-features/es-syntax": [
"error", {"ignores": ["dynamicImport", "modules"]}

And that’s it for ESLint. Simple, right? Well, a year ago, it was MUCH more complicated than that, which shows you that things are progressing! On to the next tools, Mocha and Chai.

Mocha and Chai #

Mocha was actually the first test runner to support ESM (support written by yours truly 😊). So write your test files using ESM with no problem. The only problem is a minor one, for Mocha: it is a CommonJS packages, and has no ESM wrapper, and so importing it is a two step process: the first line imports the default import, and the second line deconstructs the test functions describe and it. Note that most people tend to never import mocha so that shouldn’t even be a problem for them.

// test/test.js
import mocha from "mocha"
const {describe, it} = mocha
import {expect} from "chai"

There’s a pull request in Mocha to add a ESM wrapper, so it’s just a matter of time till this problem is als solved for Mocha too. Funnily enough, this problem also existed for Chai, but a new version that dealt with this was released while I was writing this guide.

Moreover, in terms of ESM support, I’ve seen more and more packages adding ESM wrappers to enable named imports, and it feels like the community is rallying around support for ESM in Node.js.

Mocking imports using Testdouble #

What do I mean by “mocking imports”? It’s the ability to mock a module so that when you import it, you don’t get the real module, but rather a mock of the module.

The only module mocking library that currently supports ESM is Testdouble (whose support was also written by yours truly 😊), so if you do module mocking using libraries such as proxyquire then you’re out of luck.

Gotcha: currently, the only library that supports mocking ESM modules is Testdouble. Note that regular function mocking has no problems in any library, as it is not done with modules, so you can use regular mocking library (like Sinon) in ESM without any problem.

Testdouble, and other mocking libraries in the future, use a ESM-only feature called loaders that enable a module to hook into the ES module loading process. For a module to be a loader, it needs to be declared as such in the command line. Which is why we need to run Mocha thus:

mocha --loader=testdouble test/test.js ...

(and get a scary looking “loaders are experimental” warning. Because, well, they are experimental and not yet stable! Fortunately, Testdouble shields you from the instability of the API, and will probably support any changes in the loader api that are forthcoming.)

Now, to use mocking, you can just use the appropriate Testdouble function, td.replaceEsm.


// test/test.js
td.replaceEsm("../src/add.js", { add: () => 44 })

And it will replace the add function in add.js with the above mock. Simple and efficient.

That’s it for tooling. There’s just one more (very important!) tool missing in our toolbox…

TypeScript #

Companion code:

I gave a talk last year, and said that TypeScript wasn’t yet ready for ESM in Node.js. Not sure what changed, but it’s definitely ready now. Let’s first look at the tsconfig.json.


// tsconfig.json
"compilerOptions": {
"lib": ["es2020", "DOM"],
"target": "ES2020",
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true
// ...

All the above options are necessary to make TypeScript transpile the code and keep the import so that they work with ESM. There’s just one gotcha, and it’s not even a gotcha, as this is by design: because ESM in Node.js needs to write relative paths with extensions, you must write the file extensions in TypeScript too, and they have to be .mjs or .js! Let’s look at how this looks in the code.


// src/anner-in-color.ts
import {add} from "./add.js"

Even though add.ts is a TypeScript file, we still write ./add.js and not ./add.ts. This is by design of TypeScript and is not a bug. Weird, though, right?

So just remember this weirdness when you’re importing files, modify your tsconfig.json and start transpiling your TypeScript code to ESM! Just don’t forget that the main entry point is now in the lib directory that contains the transpiled code.


// package.json
"main": "./lib/main.js",
"exports": "./lib/main.js",

Bonus: using JSDoc typings with ESM #

Given that I wrote a whole blog post on JSDoc typings, I would be amiss if I didn’t say that JSDoc typings also work well, with the same tsconfig.json (just don’t forget to turn on allowJS and checkJS). You can see an example project that uses JSDoc typings and ESM in “08-jsdoc-typing”.

Summary #

This guide is over. I’ve shown:

Hope you enjoyed this guide!

By the way, I’m sure you’re using tools that I haven’t covered. I’d love to hear about them and if there were any ESM gotchas or configurations to do to support it. Drop me a Twitter DM at @giltayar: I’d love to add the information to this document.