Node.js ECMAScript Modules

Chapter 5 26 mins

Learning outcomes:

  1. What are ECMAScript modules
  2. The .mjs file extension
  3. Setting type in package.json
  4. Using import.meta in an ECMAScript module
  5. The import() function

Introduction

The original module system that shipped with Node.js and continued to power it for a very long time was CommonJS modules, often simply referred to as CommonJS in Node.js speak. We covered CommonJS modules in great detail in the last chapter.

With the inception of the ECMAScript 2015 (ES6) standard, the JavaScript world got introduced to a standard module system — ECMAScript modules. We've covered ECMAScript modules in depth in the Modules unit from our Advanced JavaScript course.

Following this standard's publication, browsers soon started to support ECMAScript modules out of the box. More and more frontend tooling came to appreciate the ES6 module system and enable one to work with it.

However, for the Node.js community, things went a lot more slowly, for they had to make sure that the entire existing ecosystem of packages and libraries written in CommonJS don't get rendered useless with the introduction of a new, and arguably better, module system.

Almost five years later, in 2020, Node.js finally shipped with full support of ECMAScript modules, concisely referred to as ESM.

In this chapter, we shall get a brief overview of ECMAScript modules; how to transition the Node.js runtime to use ESM by default; the .mjs file extension; the "type": "module" property of package.json; and much more on this road.

Understanding the ESM system is as important for any Node.js developer as understanding the CommonJS module system. In fact, this holds much more for ESM because it's an ECMAScript standard, used in browsers as well, and undoubtedly the future of modules in Node.js.

What are ECMAScript modules?

In this section, we'll very quickly skim through the basics of ECMAScript modules.

For an in-depth discussion on the evolution of modules in JavaScript and the semantics of ECMAScript modules in general, refer to Advanced JavaScript — Modules Introduction.

As stated above, when ECMAScript 2015 came in, it brought with it a new system of modules in JavaScript. However, Node.js was already comfortable using its own module system, CommonJS, as we know of it.

But quite expectedly, it had to be the case for Node.js to sooner and later shift to this standard system because the ECMAScript specification defines core JavaScript and it can't possibly be that Node.js is without a standard JavaScript feature.

Plus, there were a couple of benefits of using ECMAScript modules over CommonJS ones, for e.g. ES modules could be optimized using static analysis (thanks to the fact that ES modules have to import everything right at the beginning of the file).

And so eventually support for ES modules landed in Node.js, initially experimentally but later on completely.

So what exactly is an ECMAScript module?

An ECMAScript module, or ES module, or ESM file, is essentially a JavaScript file where we can import and export data using standard JavaScript constructs.

Let's start with exploring how to export data.

The export keyword is used for exporting data from an ECMAScript module.

export can be used to perform:

  • Named exports — exporting individual identifiers from the module (similar to setting new properties on the exports object in CommonJS)
  • Default exports — exporting one single value from the module (similar to assigning a value to module.exports)

Below shown is an example of exporting two constants PI and E from an ESM file constants.mjs (we'll get to what is .mjs in a while) as we did in the previous chapter with a CommonJS module:

constants.mjs
export const PI = Math.PI;
export const E = Math.E;

These two here are instances of named exports: the first named export is PI whereas the second one is E.

Notice how the export keyword is immediately followed by a declaration statement, in this case a const declaration.

This is a rule of using export: it must be followed by an identifier declaration.

So, we can't have the following:

export PI = Math.PI; // This is invalid!
export E = Math.E; // And so is this

Anyways, so now that we have exported the constants from constants.mjs, the next step is to import them.

For importing data into a module, ECMAScript introduced the import keyword.

In the following code, we import the two constants PI and E from constants.mjs into main.mjs (which is in the same directory as constants.mjs):

main.mjs
import { PI, E } from './constants.mjs';

console.log(PI);
console.log(E);

Let's see what's happening in the import statement.

We begin with the import keyword followed by a pair of curly braces ({}) containing the individual named imports that we ought to make. Next comes the from keyword followed by a module specifier.

The module specifier is simply a string representing the path to the module file to import data from. In this case, the module specifier is a relative path, which would be apparent reading the ./ part in the path.

Here's what the main.mjs file displays in the console:

3.141592653589793
2.718281828459045

Just what we got in the previous chapter when we imported the identifiers from a constants.js CommonJS module. Perfect.

Now, let's clear the mist away from what exactly the .mjs is and why we used it in naming both of the files constants.mjs and main.mjs.

The .mjs file extension

In the elementary example above, notice that both of the files constants.mjs and main.mjs ended with an .mjs extension instead of the more common .js extension.

What's going on? What's the .mjs doing here?

Well, to start simple, .mjs is an extension introduced by the Node.js community to be able to distinguish whether a JavaScript file is a CommonJS module or an ECMAScript module.

A JavaScript file with the .mjs extension is treated as an ECMAScript module by Node.js.

If you're curious to know, the 'm' in 'mjs' stands for 'module'.

By default, Node.js treats every .js file as a CommonJS module (unless we modify so by using an input flag while calling node in the terminal or by setting "type": "module" in package.json; more on both these later on).

In this respect, if Node.js then encounters an import or export keyword in a .js file (given that it is loaded as a CommonJS module), it raises an error.

Let's see an example of this.

We'll rename the main.mjs file that we created above to main.js and then execute it again:

main.js
import { PI, E } from './constants.mjs';

console.log(PI);
console.log(E);

Surprisingly, but correctly, this gives us an error:

(node:1688) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. (Use `node --trace-warnings ...` to show where the warning was created) C:\Users\codeguage\learning-node\main.js:1 import { PI, E } from './constants.mjs'; ^^^^^^ SyntaxError: Cannot use import statement outside a module

If we try understanding the error message, it's not hard to see what's going on:

The parser squawks upon encountering the import keyword in the main.js file because it does NOT expect import to appear in a CommonJS module; the main.js file is treated as a CommonJS module because that's the default in Node.js.

Desirably, the error message also points us to ways to get rid of the error:

  • Either to give the .mjs extension to the file; or
  • Set "type": "module" in package.json (we'll explore this in the next section below).

And so this is the reason why we named both the files constants.mjs and main.mjs in the section above with the extension .mjs.

Setting type in package.json

As we learnt just a minute ago, by default Node.js treats a .js file as a CommonJS module. If we wish to execute the file as an ECMAScript module, we need to change its extension to .mjs.

However, using the package.json configuration file, we can get Node.js to change this default behavior and instead treat every .js file as an ESM file to begin with.

This is done by setting the type property of the main object to the value "module".

When the node command is invoked with a given JavaScript file's path, the Node.js runtime first searches for a package.json file in the same directory as the file and if it finds one, looks further for the type property in the contained JSON object.

In case the property is also found, its value decides whether Node.js executes the given JavaScript file (with the .js extension), and every subsequent .js file, as a CommonJS module or as an ECMAScript module.

There are only two possible values of type:

  • "module" — means that the default module type is ESM.
  • "commonjs" — means that the default module type is CommonJS (this is the default value in case the property is omitted).

In our case, we seek to use the value "module" in order to get every .js file be invoked as an ECMAScript module.

Let's try doing so now.

Recall the result we got above when we renamed main.mjs to main.js and then executed the file as usual:

main.js
import { PI, E } from './constants.mjs';

console.log(PI);
console.log(E);
(node:1688) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. (Use `node --trace-warnings ...` to show where the warning was created) C:\Users\codeguage\learning-node\main.js:1 import { PI, E } from './constants.mjs'; ^^^^^^ SyntaxError: Cannot use import statement outside a module

We got an error because we tried to use import in a file treated as a CommonJS module.

Now, let's add in the type property in the underlying package.json file and set it to "module" before executing this main.js (with the .js extension) again:

package.json
{
   ...
"type": "module",
}

Here's what we get upon running main.js now:

3.141592653589793
2.718281828459045

The code executes successfully, all thanks to "type": "module" which instructs Node.js to treat .js files as ECMAScript modules and not as CommonJS ones.

That's amazing.

It's worth noting here that if we try using CommonJS module features in ECMAScript modules, we'll get an error akin to how we'll get one when we try to use ECMAScript module features in CommonJS modules.

An example is shown below:

constants.js
exports.PI = Math.PI;
exports.E = Math.E;
main.js
const { PI, E } = require('./constants');

console.log(PI);
console.log(E);

We are referring to the require() function in our main.js file. Remember that the main.js is treated as an ECMAScript module, thanks to the application of "type": "module" in the underlying package.json file.

Let's see what gets output in the terminal when we run this program:

file:///C:/Users/codeguage/learning-node/main.js:1 const fs = require('fs'); ^ ReferenceError: require is not defined in ES module scope, you can use import instead This file is being treated as an ES module because it has a '.js' file extension and 'C:\Users\codeguage\learning-node\package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension. at ...

As expected, we get an error.

The error clearly says that require() is not available in an ECMAScript module and that, for importing stuff, we should rather stick to using the import keyword.

But, with this setup, what if we want to execute a given JavaScript file as a CommonJS module? What if we really need the semantics of a CommonJS module?

Enforcing the CommonJS module type

Well, similar to how we can use the .mjs extension to enforce a file to be executed as an ESM file when the default configuration is set to CommonJS, we can use the .cjs extension to achieve the opposite result.

That is, when the default configuration is set to ESM, we can enforce a JavaScript file to be executed as a CommonJS module by giving it the .cjs file extension.

Quote obviously, 'cjs' stands for 'commonjs'.

Let's update the extension of the two CommonJS-style files above from .js to .cjs in order to get them to be executed as CommonJS modules.

constants.cjs
exports.PI = Math.PI;
exports.E = Math.E;
main.cjs
const { PI, E } = require('./constants.cjs');

console.log(PI);
console.log(E);
3.141592653589793
2.718281828459045

Notice one crucial addition here: we have included the file extension .cjs in the path provided to require() for importing constants.cjs — we can NOT refer to the constants.cjs file without specifying its extension.

This is simply because when there is no extension in the path provided to require(), Node.js automatically tries to look for files ending in .js, .json, or .node, in this order.

Clearly, because neither of these extension is .cjs, a .cjs file is effectively never found by Node.js automatically. For this reason, we must make sure to specify the .cjs extension explicitly in the path provided to require().

Using import.meta

Recall the __dirname and __filename special variables made available by Node.js in a CommonJS module? (And do you also recall how are they made available?)

Consider the following code of a file main.cjs, where we demonstrate them:

main.cjs
// This is a CommonJS module
console.log(__dirname);
console.log(__filename);

To be explicit about the module type of the file, we've given it the .cjs extension.

Following is the produced output:

C:\Users\codeguage\learning-node C:\Users\codeguage\learning-node\main.cjs

__dirname holds the complete path to the directory containing the main.cjs file (in this case, the learning-node directory).

__filename holds the complete path to the main.cjs file which is merely obtained by appending the file's name to __dirname after adding the respective directory separator (\ in this case).

Unfortunately, since both these variables are features of CommonJS, they are unavailable in an ECMAScript module:

main.mjs
// This is an ECMAScript module
console.log(__dirname);
console.log(__filename);
file:///C:/Users/codeguage/learning-node/main.mjs:2 console.log(__dirname); ^ ReferenceError: __dirname is not defined in ES module scope ...

Now what?

What if we want the complete path to an ECMAScript module and/or the directory containing that module?

Well, rest assured, we can very easily do so using the special import.meta object, specifically using its url property.

import.meta is not a proprietary feature of Node.js; it's part of the ECMAScript standard. In other words, import.meta is also available in an ECMAScript module in the browser.

import.meta.url points to a file URL, i.e. a URL based on the file URI scheme, for the underlying module.

To extract the file path from this URL, we can leverage the URL() constructor (implemented in Node.js using on the same semantics on which the URL() constructor is based in the browser) from the Node's url module and then its pathname property.

And to extract the directory path from the obtained file path, we can leverage the dirname() method from Node's path module.

Let's consider an example to understand this better.

In the following code, we output the value of import.meta.url in our main.mjs file:

console.log(import.meta.url);
file:///C:/Users/codeguage/learning-node/main.mjs

The return value begins with the file: scheme because as per the ECMAScript specification, the value of import.meta.url must be a valid URL.

/C:/Users/codeguage/learning-node/main.mjs is a valid file path but it's NOT a valid URL.

Now using this return value of import.meta.url, let's extract the directory path and the file path of the module:

import { URL } from 'node:url';
import path from 'node:path';

const fileURL = new URL(import.meta.url);
const filename = fileURL.pathname;
const dirname = path.dirname(filePath);

console.log(dirname);
console.log(filename);
/C:/Users/codeguage/learning-node /C:/Users/codeguage/learning-node/main.mjs

Simple, yet perfect!

The import() function

One feature that perhaps many CommonJS module users take for granted is that require() is a function and, in that sense, could be called anywhere in a script.

This effectively means that we can perform conditional imports, also known as dynamic imports, based on the outcome of given conditions.

For instance, in the following code, we only import the constants.cjs file if there is a variable needConstants set to true:

main.cjs
var needConstants = true;

if (needConstants) {
   console.log('Importing constants.cjs');

   const { PI, E } = require('./constants.cjs');
   console.log(PI);
   console.log(E);
}
else {
   console.log('Not importing anything');
}
Importing constants.cjs
3.141592653589793
2.718281828459045

If we change needConstants to false, here's what happens:

Not importing anything

Now a question that stands to be addressed in this section is: How to perform a dynamical import in an ECMAScript module?

We already know that require() ain't available to us in an ECMAScript module and, likewise, we certainly can't perform a dynamic import using it.

Fortunately, the ECMAScript standard realized this long ago while developing its module system and crafted a feature to address it — the dynamic import() function.

The import() function is used to dynamically import an ECMAScript module anywhere in a program.

Most importantly, the import() function returns back a promise that resolves with an object acting as a namespace for the imported ECMAScript module.

There is a fine line between the import keyword and the import() function. The keyword does NOT represent a function definition; it's merely a syntactic element of the language, whereas the function is just a function without any significance of the name import.

Let's consider an example to help clarify this a bit.

Below, we'll replicate the CommonJS code above using an ECMAScript module, but first let's review our constants.mjs file:

constants.mjs
export const PI = Math.PI;
export const E = Math.E;

Great. We have two named exports: PI and E.

Now, let's get to the real business:

main.mjs
var needConstants = true;

if (needConstants) {
   console.log('Importing constants.mjs');

import('./constants.mjs').then(({ PI, E }) => { console.log(PI); console.log(E); });
} else { console.log('Not importing anything'); }
Importing constants.mjs
3.141592653589793
2.718281828459045

Here, the import() function is used to import the constants.mjs ECMAScript module dynamically inside the if block.

And because it returns a promise, we invoke the then() method on it with a callback extracting the named exports PI and E from the namespace object and then logging it.

We can also simplify the promise using the more modern await syntax, as depicted below:

main.mjs
var needConstants = true;

if (needConstants) {
   console.log('Importing constants.mjs');

const { PI, E } = await import('./constants.mjs'); console.log(PI); console.log(E);
} else { console.log('Not importing anything'); }

What we're using here is an instance of top-level await in JavaScript, i.e. await outside of an async function.

Keep in mind that the top-level await syntax is only permissible in an ECMAScript module; you can't use it in a CommonJS module.

Whatever approach we choose to take, it's vital to remember that import() returns back a promise and that it behaves asynchronously; we can't just go on and inline import() in a piece of code without giving care to its asynchronous behavior.