Native ES Modules in NodeJS: Status And Future Directions, Part I

ES Modules are coming to NodeJS. They’re actually already here, behind a --experimental-modules flag. I recently gave a talk at NodeFest Japan about the current state of ES Modules in NodeJS, and would like to do the same here, but also talk about the future of ES Modules in NodeJS, as discussed in Myles Borins paper found here, and as I think about it.

But first, let’s see how ES Modules are implemented in NodeJS. Theoretically, this should have been a simple thing — just implement the spec in NodeJS, and be done with it. The spec is pretty well defined, so where’s the problem? Why can’t this post just be, “ES Modules are now natively implemented in NodeJS, so go forth and use them”?

There are three main complications:

  1. The obvious one is CommonJS, which is the current module system used by NodeJS (the one that uses requireandmodule.exports). CommonJS already is a module system for NodeJS, and ES Modules has to learn to live side by side and interoperate with it. The whole thing is complicated by the fact that ES Module loading is asynchronous in nature, while CommonJS is synchronous by nature.
  2. The less obvious complication is browser compatibility. The NodeJS ecosystem initially started as a separate ecosystem, but since Browserify and webpack enabled CommonJS modules to be used in the browser, the scene of isomorphic code, which runs both under NodeJS and the browser, has exploded.
  3. The third complication, ironically enough, is babel and webpack (and any other bundler like rollup, browserify, and parcel). These pair of tools enabled both NodeJS and browser code to use ES Modules without native support. Thus, lots of code out there in the wild uses ES Modules as specified by babel/webpack, but which is different from the final way. Why different? Because babel/webpack load ES Modules synchronously, while the specification of ES Modules specify asynchronous loading.

But first, let’s understand how ES Modules are implemented in NodeJS.

(For an overview of the syntax of ES Modules, see here for Axel Rauschmayer’s excellent article.)

Native ES Modules in NodeJS

To create an ES Module, just create a file with the mjs extension that includes the module code. For example:

To use it, just import the file from another mjs file:

(All the sample code in this post can be found here, including the tests that run them using the experimental modules flag.)

mjs, or Michael Jackson Scripts

This is all very simple — exactly according to spec — but there is something peculiar here: the mjs extension used. Can’t we just use the js extension? The answer, according to the current implementation in NodeJS, is unfortunately no.

Why? Because the JavaScript specification defines some differences between a file that is an “ES Module”, which is called a “module” in the spec, and a file which are not ES Modules, which is called a “script”. One example of such a difference is that “modules” are, by definition, “strict” (i.e. it’s as if they have a “use strict” implicitly at the top). Another important difference is that scripts are not allowed to use import from statements. These differences means that in order for the JavaScript engine to be able to execute or import the file, it needs to understand whether the file is a module or script, and this information about how to know that is not given by the JavaScript specification. It has to be out of band.

There were numerous ideas around how to specify whether a file is a module or a script:

  • A field in the package.json that defines whether all the files in this package.json are scripts or modules.
  • A header like "use module" in the top of the file, similar to "use strict".
  • The existence of an export statement means that the file is an ES Module, even if it is a dummy export default {}.
  • A different extension for the file.

I don’t want to get into the reasons that led the NodeJS implementors to choose the last option (and I wasn’t privy to the decision process. While it’s all open on the web, it is difficult to understand). But other than the aesthetic problem of using a different extension, it is a good decision. The idea that just by looking at the extension you can know if the file is ESM (ES Modules) or CJS (CommonJS) is an important idea.

This decision led to what I call The Rules of ES Modules:

  1. A file is ESM if and only if the extension is “mjs”
  2. A file is CJS if and only if the extension is “js”
  3. Only ESM is allowed to use export/import statements
  4. Only CJS is allowed to use import CJS using require

These four rules create a moat between ESM and CJS:

mjs and js cannot import one another?

If ESM modules can only import other ESM modules, and CJS can only import other CJS files, how can we create interoperability between the two module systems?

Interoperability between CJS and ESM

This leads us to two additional rules, which I call The Rules of Interoperability:

  1. CJS can import ESM, but only using await import()
  2. ESM can import CJS using the import statement, but only a default import

This bridges the gap between the two module systems:

Bridging the two module systems

Let’s see the rules applied to a JS file, which wants to import an ES Module. Let’s start by trying to require it:

The above code will fail with an error, because an ESM (mjs) file cannot be imported usingrequire. Let’s try again:

Fails with error, again, because a CJS cannot use an import from statement. So how? Well, if we look at The Interoperability Rules, we see we need an await import. Unfortunately, using await means that the code that imports it needs to be in an async function (or its equivalent in promise or callback land). But we can do it:

What about the other direction? An ESM file wanting to import a CJS one? Let’s try require-ing it:

This fails, because according to The Rules of ESM Modules, an ESM file cannot use require. So how? If we look at The Rules of Interoperability, we see we that we can use import to import a CJS file (but only with a default import):

The default import will return the module.exports value that the CJS file exported.

dynamic importis not currently implemented in Node 9, but is in the pipeline, and mostly awaiting support from v8.

Migrating Applications from CJS to ESM

The Rules of ESM Modules and The Rules of Interoperability provide a clear path to understanding how to migrate an application from CJS to ESM. Fortunately, the process is pretty simple, mostly technical, and can be incremental:

For each file in your application that you want turned into ESM, you need to:

  1. Rename its extension to mjs.
  2. Change the exports from CJS ( module.exports) to ESM (export statement).
  3. Change all require-s in it to import statements with default import.
  4. If the require is dynamic, change it to await import(). This is the only non-technical transformation, as the function it is in must now be asynchronous, i.e. using async, promises, or callbacks.
  5. If the module uses __dirname (or __filename) , use the following “polyfill”
    const __dirname = path.dirname(
    new url.URL(import.meta.url).pathname)
    (if you want to know more about import.meta, see here).
  6. Change all require-s in other files that reference it to import.

That’s it. Pretty simple. If your application is small, I would advise doing it in one try, but if the application is large, this can be done incrementally.

import.meta.url is not currently implemented in Node 9, but is in the pipeline, and mostly awaiting support from v8.

Bare Imports

What about libraries? Up to now we explored application files importing one another. The ES Module spec says that if the path to the file is a URL, or starts with ".", then the path to the file is according to that path. But libraries in NodeJS are require-ed using a path like "lodash".

This kind of “path” is usually called a “bare import”, e.g. require("lodash"). How does NodeJS find the file to require when the import is a “bare import”? It’s actually pretty complicated, but the resolution algorithm is specified pretty precisely here. The essence of it is searching for packages in the node_modules directory, and it is this algorithm that is the heart of the CJS module system and which enables the growth of the biggest package repository in the world, the npm repository.

npm is the biggest package repository in the world (by far!)

Incredibly enough, the ES Modules spec says nothing about bare imports, except to note that the module resolution is currently unspecified. This leaves NodeJS to do whatever it wants with them.

And what it does is implement the same resolution algorithm it uses for CJS, but for ESM. So that, for example, if you do import _ from 'lodash', it will look for a lodash directory in node_modules, and look for its package.json to determine the file to load (please note that this is an oversimplification of the real resolution algorithm).

Dual-Mode Packages

Which brings us to the question of writing npm packages that support ES Modules. How does one go about migrating a CJS package so that it can be used as an ES Module? Theoretically, we can use the same steps as migrating an application from CJS to ESM. But once we do that, the package can only be used as an ES Module, and CJS “clients” cannot use it anymore.

The answer to this conundrum is to write the package as a dual-mode package, i.e. a package that can be used both as an ES Module and as a CJS Module. How? Before answering that, I want to introduce the last set of rules for this blog post, The Rules of Migration:

  1. require-ing a file with no extension resolves to a .js file
  2. Importing a file with no extension resolves first to a .mjs file (and only if not found, to a .js file)
  3. Resolving bare imports is the same in CJS and MJS (except for which extension is used)

This rule means that you can write a foo.js file that is besides a foo.mjs, and if you require('./foo'), it will use the foo.js file, and if you import ... from './foo', it will use the foo.mjs file.

This points us to the final answer to how to write a Dual-Mode Package:

  1. Convert the code to use ESM files (same steps as the steps in the application migration).
  2. The entry point in the package.json should be without extension, e.g. index and not index.mjs.
  3. Write a build step that uses babel to transpile the mjs to js.

An example package.json that implements all this:

With the accompanying .babelrc:

And, finally, a package that implements it: https://github.com/giltayar/node-esm-tea/tree/master/src/03-migration/dual-cjs-mjs-package

Summary

The three rules below encapsulate all you need to know about ES Modules in NodeJS, and all you need to know to migrate your apps from CommonJS to ES Modules, and to write dual-mode packages that work both as CommonJS packages and as ES Module packages.

In the next post in the series, I will talk about what needs to be added to ensure browser compatibility, and some level of compatibility with “babel ES Modules”.

The Rules

The Rules of ES Modules

  1. A file is ESM if and only if the extension is “mjs”
  2. A file is CJS if and only if the extension is “js”
  3. Only ESM is allowed to use export/import statements
  4. Only CJS is allowed to use import CJS using require

The Rules of Interoperability

  1. CJS can import ESM, but only using await import()
  2. ESM can import CJS using the import statement, but only a default import

The Rules of Migration

  1. require-ing a file with no extension resolves to a .js file.
  2. Importing a file with no extension resolves first to a .mjs file, and only if not found, to a .js file.
  3. Resolving bare imports is the same in CJS and MJS, except for which extension is used.

Visual Testing

I work at Applitools, and what we do is pretty amazing too — we build tools that enable you to visually test your frontend code — meaning automatically test that your components still look like they should, and haven’t gone all whacky due to CSS, HTML, or JS changes. We’ve just finished working on an amazing tool that can visually test all the components in a React Storybook, without writing a single line of code.

I’m pretty proud of it, and wrote a blog post about how awesome it is. Feel free to check it out if you’re into testing your React components.

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.