Original Photo by Andreas from Flickr

Migrating an NPM package to use ES Modules

Brett Uglow
DigIO Australia

--

This article follows on from Migrating to GitHub Actions.

In this article, I’m going to show you the steps needed to convert an NPM package from using CommonJS (CJS) modules, to the newer ES module (ESM) format. At the end is a list of resources I used to compose this article. The screenshots come from this code-diff.

Background

Javascript (JS) originally didn’t have a way to define code within a module. The only way to load a JS file was via a <script> tag in a browser. If multiple files were loaded into the browser, they shared the same memory space. This meant that it was possible for two JS files to contain a top-level function called run(), and the file that was loaded last would contain the working run() function. This is known as a naming collision, and was a result of a lack of a module specification for JS.

When NodeJS came along in 2009, there were several competing ways to “wrap” code so that it could be reused alongside other scripts. NodeJS chose the CommonJS approach, which rose in popularity as NodeJS did itself. By 2015, it was the dominant module format. But the JS working group — TC39 — had been busy defining an official module format for ECMAScript (aka JavaScript): ECMAScript Modules (ES Modules or ESM).

Introducing a new module-format is a big deal. It’s akin to defining a new programming language and hoping that developers are prepared to migrate their existing code to the new language. Thankfully, the benefits of ESM persuaded many devs to support the standard and write tools like Babel and Webpack which allow CJS modules & ES modules to be used in the same codebase, easing the migration effort.

Skip forward to 2021, and Node 14+ has full support for ESM out-of-the-box! It has had experimental support for a couple of years, but now there are fewer steps required to get going. So let’s migrate an NPM package to take advantage of ES modules, step-by-step.

Steps:

  1. Change package.json
  2. Update ESLint & Prettier configs
  3. Change source code
  4. Change test framework (Jest)
  5. Other tooling changes

Step 1 — Change package.json

package.json module & exports changes

There are couple of things to change in package.json:

  • Add "type": "module", which tells NodeJS that the JS files in the package should be treat as ES modules instead of CJS modules
  • Replace "main": "src/index.js" with "exports": "./src/index.js" , which also allows specifying multiple exports (and conditional exports if you wish to support CJS & browsers, for example)
  • Set the "engines" field in package.json to Node.js 14+: "node": " >=14.13.1 || >=16.0.0"
package.json engine changes

As soon as I made those changes, my IDE started complaining about the source code, so let’s fix that.

Step 2 — Update ESLint & Prettier configs

ESLint config changes

If you defer this step to later, when updating your source code you will get lots of false linter-warnings in your IDE. By doing this first, the warnings in your IDE will become real things you should fix.

Unfortunately ESLint doesn’t yet support an ESM config file. So here’s what you need to do:

  • Rename .eslintrc.js to .eslintrc.cjs
  • Add sourceType: 'module' to the parserOptions

If you are using the node/recommended ESLint plugin, you also need to disable some rules (it doesn’t treat .js files inside packages with "type": "module" as ES modules, yet):

'node/no-unsupported-features/es-syntax': ['error', { ignores: ['dynamicImport', 'modules'] }],

If you use Prettier, it also expects the config as CJS (called .prettierrc.js) or JSON (called .prettierrc). Renaming the file to .prettierrc.cjs didn’t work, so I went with the JSON approach.

Prettier config (with 120 char line widths 🤫)

Step 3— Change source code

There are 2 main things to change:

  1. Replace all require() calls with import statements:

Additionally, if there are use strict; lines in any JS files, those can be removed now.

2. Replace module.exports with (preferably “named”) export statements:

Replacing module.exports with named exports

Step 4— Change the tests

This is really easy if you haven’t used Jest’s mock feature. Currently Jest does not have a way to mock ES modules the way it can mock CJS modules. So if you have used jest.mock, you have 2 choices:

  1. Refactor the code so that you don’t need jest.mock, OR,
  2. Use testdouble instead of Jest.

We’re going to go with the refactoring approach (1), as it means we can keep using Jest.

Getting Jest running

  1. At the time of writing, Jest requires an extra flag to be passed to Node to execute test specs that are ES modules: node — — experimental-vm-modules node_modules/.bin/jest .
package.json

2. Import import {jest} from @jest/globals in each test-spec that references jest.fn() (or other Jest functions). Previously Jest was able to inject the jest property onto the global object, but this is no longer possible when using ESM.

The jest global needs to be imported now.

Refactoring the test specs

The same changes that need to be made to the source code, also need to be made to the test specs. Additionally, there were some extra steps:

  1. Convert __dirname to:
import {fileURLToPath} from 'url';

const foo = fileURLToPath(new URL('foo.js', import.meta.url));

Note that this guide suggests using the node: protocol when importing Node’s built-in packages e.g. import fs from 'node:fs'. However, Jest does not recognise this protocol yet, so just use import fs from 'fs' for now.

2. Replace jest.mock('fs') so that the fs package is not mocked, but reset the file system after each test instead. This provides a more realistic test at the expense of a little more setup & teardown work.

There’s probably a nicer way to mock the fs package using an in-memory filesystem (which is essentially how the previous mock was working), but using the real file system isn’t terrible for this particular use case. YMMV.

Step 5 — Other tooling changes

While upgrading this package, I took the opportunity to upgrade most of the development dependencies — ESLint, Prettier & Husky. It’s also a good idea to update any ESLint plugins at the same time, as the latest versions should be compatible with each other.

Finally, don’t forget to update your documentation!

README.md update: CJS → ESM

Resources

--

--

Brett Uglow
DigIO Australia

Life = Faith + Family + Others + Work + Fun, Work = Software engineering + UX + teamwork + learning, Fun = travelling + hiking + games + …