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:
- The obvious one is CommonJS, which is the current module system used by NodeJS (the one that uses
require
andmodule.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. - The less obvious complication is browser compatibility. The NodeJS ecosystem initially started as a separate ecosystem, but since
Browserify
andwebpack
enabled CommonJS modules to be used in the browser, the scene of isomorphic code, which runs both under NodeJS and the browser, has exploded. - 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.)
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 dummyexport 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:
- A file is ESM if and only if the extension is “mjs”
- A file is CJS if and only if the extension is “js”
- Only ESM is allowed to use
export
/import
statements - Only CJS is allowed to use import CJS using
require
These four rules create a moat between ESM and CJS:
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:
- CJS can import ESM, but only using
await import()
- ESM can import CJS using the
import
statement, but only a default import
This bridges the gap between 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
import
is currently implemented only from Node 10.
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:
- Rename its extension to
mjs
. - Change the exports from CJS (
module.exports
) to ESM (export
statement). - Change all
require
-s in it toimport
statements with default import. - If the
require
is dynamic, change it toawait 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. - If the module uses
__dirname
(or__filename
) , use the following “polyfill”const __dirname = path.dirname(
(if you want to know more about
new url.URL(import.meta.url).pathname)import.meta
, see here). - Change all
require
-s in other files that reference it toimport
.
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 currently implemented from Node 10.
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.
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:
require
-ing a file with no extension resolves to a.js
file- Importing a file with no extension resolves first to a
.mjs
file (and only if not found, to a.js
file) - 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:
- Convert the code to use ESM files (same steps as the steps in the application migration).
- The
entry
point in thepackage.json
should be without extension, e.g.index
and notindex.mjs
. - Write a build step that uses babel to transpile the
mjs
tojs
.
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
- A file is ESM if and only if the extension is “mjs”
- A file is CJS if and only if the extension is “js”
- Only ESM is allowed to use
export
/import
statements - Only CJS is allowed to use import CJS using
require
The Rules of Interoperability
- CJS can import ESM, but only using
await import()
- ESM can import CJS using the
import
statement, but only a default import
The Rules of Migration
require
-ing a file with no extension resolves to a.js
file.- Importing a file with no extension resolves first to a
.mjs
file, and only if not found, to a.js
file. - 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.