Skip to content

ES Modules in Node.js

5 min read

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

  1. ESM is strict
  2. ESM is browser compatible
  3. ESM is statically parsed
  4. 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 imported or requireed. 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.

Hungry for knowledge? 🧠

My curiosity has led me to the farthest corners on the web — from reading documentation hours on end, to diving deep into a repository wanting to figure out how a feature really works.

The beautiful thing about learning is that no one can take it away from you

Knowledge is best shared. Whenever I read a book, watch a talk, or go through a course, I write down my notes so devs like you can learn from them.

Drop your email below and I'll make sure to send nuggets of gold your way. 🍬

No spam! 🙅🏻‍♀️ Unsubscribe at any time.

You might also like

How to Fix Your EJS Modals When They Don't Show the Correct Info

Does your EJS page behave strangely? Let's examine the id attribute and how this might be the cause of the problem.
Read article

Logging with Pino and AsyncLocalStorage in Node.js

From a lousy dump of debug statements to a powerful debugging tool. Learn how to apply contextual logging in Node.js.
Read article

ESLint Setup in Node.js: A Detailed Guide

Unlock the power of ESLint in Node.js! Dive into this beginner-friendly guide for seamless integration and smarter coding.
Read article