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?
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:
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):
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.
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:
import { PI, E } from './constants.mjs';
console.log(PI);
console.log(E);
Surprisingly, but correctly, this gives us an error:
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:
import { PI, E } from './constants.mjs';
console.log(PI);
console.log(E);
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:
{
...
"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:
exports.PI = Math.PI;
exports.E = Math.E;
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:
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.
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.
exports.PI = Math.PI;
exports.E = Math.E;
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:
// 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:
__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:
// This is an ECMAScript module
console.log(__dirname);
console.log(__filename);
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);
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.
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);
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
:
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');
}
3.141592653589793
2.718281828459045
If we change needConstants
to false
, here's what happens:
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.
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.
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:
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:
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');
}
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:
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.
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.