ES Modules in Node.js
What are ES Modules?
Importing and exporting a module in Common JS (CJS), a.k.a. the old way:
// util.js
module.exports.add = function(a, b) {
return a + b;
}
module.exports.subtract = function(a, b) {
return a - b;
}
// main.js
const { add, subtract } = require('./util');
console.log(add(4, 5));
console.log(subtract(9, 4));
This is how it's done in ES Modules (ESM), a.k.a. the new way:
// util.mjs
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// main.mjs
import { add, subtract } from './util.mjs';
console.log(add(4, 5));
console.log(subtract(9, 4));
Also notice the change in file extension from .js
to .mjs
. This isn't always required though, more on that below.
Differences between ESM and CJS
- ESM is strict
- ESM is browser compatible
- ESM is statically parsed
- ESM is async
1. ESM is strict
'use strict'
is implied by default in ESM. These two are equal:
// index.js
'use strict';
x = 5; // ReferenceError: x is not defined
// index.mjs
x = 5; // ReferenceError: x is not defined
Editors, linters and runtimes need a way to distinguish between ESM and CJS to know how to parse a file. That's why the extension .mjs
has been invented.
There was pushback from the community because they didn't like .mjs
. A feature was added to be able to use ESM with .js
files. If you add "type": "module"
in your package.json
you're signalling that all .js
files in this project are ES Modules.
In package.json
:
{
"type": "module"
}
And in your project:
// util.js <--- ES Module using `.js` extension
export function add(a, b) {
// ...
}
export function subtract(a, b) {
// ...
}
// main.js <--- ES Module using `.js` extension
import { add, subtract } = './util.js';
2. ESM is browser compatible
Given the following two modules:
// esm-module.mjs
import { greet } from './a-module.mjs';
console.log(greet('world'));
// a-module.mjs
export function greet(someone) {
return `Hello, ${someone}`;
}
In Node.js, you can just run:
node esm-module.mjs
And it will work. In the browser, this will work too without any changes to the code:
<script type="module" src="esm-module.mjs"></script>
What happens is that the browser first fetches esm-module.mjs
, sees an import statement, and does an HTTP request to fetch a-module.mjs
.
However, this only works because we write out the filename including the extension. Omitting the extension like this: import { greet } from './a-module'
, won't work because browsers don't automatically append the extension. In this case the browser will request a non-existent file named a-module
.
Because of browser compatibility, ES Modules in Node.js won't allow you to import a file without the extension in the path.
Additionally, in contrary with CJS, importing folders doesn't work in ESM.
// main.js
import aFolder from './a-folder'; // ❌ Error [ERR_UNSUPPORTED_DIR_IMPORT]
// a-folder
// | index.mjs
// | main.mjs
// | package.json <- has "main": "main.mjs"
In ESM, you always have to specify the full path to the file. An exception to this are libraries inside the node_modules
folder.
import _ from 'lodash';
But this breaks browser compatibility!
Yes it does. But hopefully not for long. Import Maps is an extension that exists today and is being worked on to become a standard (Chrome already supports it). How it works is you add a script
tag with the type="importmap"
attribute. Inside the tag you add an object mapping of modules and their location:
<script type="module">
import _ from 'lodash';
</script>
<script type="importmap">
{
"imports": {
"lodash": "./node_modules/lodash-es/lodash.js"
}
}
</script>
3. ESM is statically parsed
In CJS you don't load a module, it's executed in order of appearance. When importing a binding from a module that doesn't exist, all code up to that point will be executed. Here's an example:
// main.js
const { hello } = require('./hello.js'); // 1. Execute hello.js
console.log(hello);
// hello.js
const { who } = require('./who.js'); // 2. Execute who.js
const { greet } = require('./greet.js'); // 5. Execute greet.js
module.exports.hello = greet(who); // 7. TypeError: greet is not a function
// who.js
module.exports.who = 'world'; // 3. Export `who`
console.log('"who" loaded'); // 4. Print to console
// greet.js
module.exports.greetings = function(someone) { // 6. Export `greetings`
return `Hello, ${someone}`;
}
This code will have the following output:
"who" loaded
TypeError: greet is not a function
In ESM, imports are loaded and parsed before execution time. Meaning that if a binding doesn't exist, no code will run at all. In other words, all imports are loaded and parsed before any code runs. Here's the same example from above written in ESM:
// main.mjs
import { hello } from './hello.mjs'; // 1. Load and parse hello.js
console.log(hello);
// hello.mjs
import { who } from './who.mjs'; // 2. Load and parse who.mjs, `who` is indeed a valid binding so all good
import { greet } from './greet.mjs'; // 3. Load and parse greet.mjs, `greet` is an invalid binding therefore throwing a SyntaxError
export const hello = greet(who);
// who.mjs
export const who = 'world';
console.log('"who" loaded');
// greet.mjs
export function greetings(someone) {
return `Hello, ${someone}`;
}
This time we're not seeing a console.log in the output like in the CJS example and instead an error is thrown immediately:
SyntaxError: The requested module './greet.mjs' does not provide an export named 'greet'
4. ESM is async
Loading and parsing imported modules happens in parallel and asynchronously. The execution needs to be synchronous, but loading and parsing don't have to be.
In ESM you can do await import('./a-module.mjs')
without blocking the event loop. Node.js will put the import in the event loop and continue execution. In contrast, in CJS a dynamic import halts execution until that module is fully executed.
This async behaviour facilitates top-level await because importing a module in ESM doesn't halt other async actions. Top-level await wouldn't be possible in CJS otherwise.
Using ESM with Babel/Typescript
Because the code is transpiled to use require
under the hood, using ESM with Babel or TypeScript doesn't give you all the benefits of native ESM.
The only benefit you get is that modules are strict by default. Transpiled ESM is not browser compatible, not statically parsed and therefore also not async and doesn't support top-level await.
Exports
in package.json
Package users are technically able to import internal library files directly:
import { prefix } from 'a-package/an-internal-file.mjs';
This creates friction between package maintainers and package users because it's harder to make changes internally since users depend on them.
ESM introduces the exports
property in package.json
that allows package owners to specify a mapping between files and import paths. When exports
is specified, imports other than those in the mapping aren't allowed:
{
"exports": {
".": "./main.mjs",
"./world": "./world.mjs"
}
}
import { hello } from 'a-package'; // ✅
import { world } from 'a-package/world'; // ✅
import { prefix } from 'a-package/a-file.mjs'; // ❌ won't work since it's not present in "exports"
Conditional exports
This is a bridge feature between CJS and ESM. It allows package owners to have their package be import
ed or require
ed. It works by specifying a mapping for the ESM and CJS main export property, like so:
{
"exports": {
".": {
"import": "./main.mjs",
"require": "./main.js"
}
}
}
With conditional exports, the Node.js ecosystem will be able to transition from CJS to ESM more smoothly. Libraries that use ESM will be able to provide backwards compatibility to CJS.
Warning to library users, do not import and require the same package! You'll end up with two instances of that package which will break if the package is a singleton. Choose one and stick with that.