Editor’s note: This article was updated on 3 March 2021.
“Before software can be reusable, it first has to be usable.” – Ralph Johnson
Modules are independent building blocks of a software program. They are basically a design pattern that implements features of modular design in programming languages. The module system is supported in many languages and is quite popular since the way dependencies are handled, packaged, and managed determines how easy it is to work with a large and growing source code.
In modular design, business logic pertaining to particular features or functionalities are packaged (modularized) in a standardized format for reusability, flexibility, and for the sake of reducing complexity. This setup affords a loosely coupled system due to a smooth interface of communication, as there are no global variables or shared state.
Although the concept of modules is quite different depending on the language, they are akin to the idea of namespaces in languages like Java. Modules enable code organization by splitting a codebase into reusable components such that each performs individual functions and can be combined or composed to form larger functionalities or an entire application.
In Node.js, the module system has come a long way from its earlier adoption of CommonJS. Today, ECMAScript modules (ES modules), which are now stable and fit for production use, are the official standard for packaging code for reuse in both client- and server-side JavaScript.
In this article, we are going to learn about ES modules in Node. However, we will briefly explore other ways of handling and organizing server-side code with CommonJS.
Why? So that we have a point of reference to recognize the benefits of ES modules. In essence, we will learn about the challenges it tries to solve that earlier module systems were not adapted to solve.
We will be looking at:
To easily follow along with this tutorial, it is advisable to have the latest version of Node.js installed. Instructions on how to do so are available in the Node documentation.
Also, for better context, readers may need to be fairly knowledgeable with the CommonJS module system in Node. It is equally welcoming for newcomers learning the Node.js module system or applying ES modules in their Node projects today.
With the release of Node version 15.3.0 (currently in v15.11.0), ES modules can now be used without an experimental flag, as they are now stable and compatible with the NPM ecosystem. Details about the stability index can be found here in the node.js ESM documentation. With ES modules, modules are defined with the use of the import
and export
keywords instead of the require()
function in CommonJS. Here is how they are used:
export function sayLanguage(language) { console.log(`I love ${language}!`); } //f.js import {sayLanguage} from './f.js'; console.log(sayLanguage('JavaScript')); //g.js { "name": "esm", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "type": "module", "author": "", "license": "ISC" } //package.json
Note: Readers should take note of the type field in the package.json file above. To be able to load an ES module, we need to set “type”: “module” in this file or, as an alternative, we can use the .mjs file extension as against the usual .js file extension. Also, from Node version 12.7.0 and 13.2.0, loading ECMAScript modules no longer required us to pass any command-line flag.
Alexs-MacBook-Pro:esm terra-alex.$ node g.js I love JavaScript! undefined Alexs-MacBook-Pro:esm terra-alex.$
The result/terminal output of running our code is shown above.
This support was previously behind the --experimental-module
flag. Currently, this is no longer required as from version 13.14.0 to 14.0.0, the implementation is now stable and can now be used with the earlier commonJS module system.
Files ending with .mjs
or .js
extensions (with the nearest package.json
file with a field
type) are treated as ES modules, as shown earlier. So, in essence, when we run node g.js
in the same folder as the above package.json
, the file is treated as an ESM. Additionally, it is an ESM if we are passing string arguments to the Node.js standard input with flag --input-type=module
. More details can be found here.
It’s important to note that with ESM, without the type
field in the parent package.json
file or the other ways specified above, Node throws the error shown below:
(node:2844) 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). Also, as an aside, we cannot make use of import statements outside of modules.
A package scope, defined by the type
flag in a parent package.json
file and all folders below it, is present in the current scope of that package, as explained earlier. Furthermore, files ending with the .mjs
extension are always loaded as ES modules regardless of the scope of that package.
In the same light, all other forms of files without extensions and without the type
flag in the parent package.json
file are treated as CommonJS. Additionally, files ending with .cjs
extensions are treated as CJS modules regardless of package scope.
It’s important to know that because Node.js now supports both ES modules and commonJS modules, package authors need to always ensure the type
field in their package.json file is always included, regardless of the module type. This allows for backwards compatibility; for instance, in a situation where the default type of Node.js changes, Node would know how to handle or interprete files in our program. More details here.
In ES modules, specifiers are like string-based file paths used after the from
keyword. There are both algorithms to load an ES module specifier and to determine the module format of a resolved URL. An example is shown below:
import {cat} from 'animals';
The animal
specifier in this case is an npm package, for example. Other ways specifiers can be referenced include from both absolute and relative file paths or URLs, and paths within other packages. Examples are shown in this section of the documentation.
Note: Node.js built-in Modules can now be loaded or referenced by absolute URL strings from version 14.13.1.When using the import keyword, a file extension must be provided to resolve relative or absolute URL specifiers.
Although import
statements are permitted only in ES modules, they can reference both an ESM or CommonJS modules. For example:
import packageMain from 'commonjs-package'; // Works import { method } from 'commonjs-package'; // Errors
Note: Import statements that reference other types of files like JSON or Wasm are still experimental and require the
--experimental-json-modules
and--experimental-wasm-modules
flags, respectively, to be passed, as they are only supported in commonJS. From Node version 14.8.0, we were able to make use of the top-level await without passing CLI flags. Details including using the await keyword outside async functions (AKA top-level await) for ESM, can be found here.
For exports in ES modules, we can make use of the following:
module.exports.name = "Alex"
export default function sayName() {console.log('My name is Mat')}
Note: All built-in Node packages support all types of exports (named, default) described above. Since Node version 14.13.0, support has been added for detection of CommonJS named exports in ESM.
There are now two fields that can define entry points for a package: main
and exports
, in a project’s package.json
file. While the main
field only defines the main entry point of the package, the exports
field provides an alternative where the main entry point can be defined while also providing the benefit of encapsulating the package.
Module files within packages can be accessed by appending a path to the package name. Another way is if the package’s package.json
contains an exports
field where the files within packages can only be accessed via the paths defined in the exports
object.
To set the main entry point for a package for example, it is advisable to define both exports
and main
in the package’s package.json
file. More details can be found in the documentation.
Prior to the introduction of ES modules, the community relied heavily on CommonJS for packaging server-side JavaScript code. In the CommonJS module system, each file is treated as a module, which exposes a set of APIs (via a well-defined interface) with the use of the exports
object. To understand this better, here is an example using the object created by the module system:
function sayName(name) { console.log(`My name is ${name}.`) }; function sayAge(age){ console.log(`I'm ${age} years old.`) }; module.exports = {sayName, sayAge}; //a.js
To use these functions (imported as modules in a different file), we can use the require
function. This accepts a module identifier (ID) specified by either a relative or an absolute path or by name, based on the module type of the exposed APIs, like so:
const {sayName, sayAge} = require('./a') // assuming a.js is in the same folder path console.log(sayName('Alex')) // My name is Alex. console.log(sayAge(25)) // I'm 25 years old. //b.js //TO RUN THE CODE SAMPLE TYPE: $ node b.js on your terminal
As we can see above, the require
object returns/imports the module content exported from the a.js
file. To learn more about the implementation of the module
, export
, and require
keywords, we can peek at the module wrapper here.
The CommonJS specification is also available here. The spec highlights the minimum features that a module system must have in order to support and be interoperable with other module systems.
The CommonJS implementation allows for a defined structure in how files are loaded. In this approach, code required from other files are loaded or parsed synchronously. For this reason, catching and detecting failure points or debugging code are easier and less tedious.
Why? Because variables present in the modules or exported files are within the scope of that module or private to it and not in the global scope, as such errors are properly propagated. Also, because of the huge separation of concern, modules are loaded from parent to child, traversing down the dependency graph.
Note: When the wrapper function returns, the exports object is cached, then returned as the return value for the
require()
method. This is like a cycle. How? On the next call to themodule.exports
method, the same process of wrapping the module with the wrapper function is repeated. But not without checking if that module is already stored in the cache.
The signature of the wrapper function is shown below:
(function(exports, require, module, __filename, __dirname) { // Module code actually lives in here });
The Module
object, which takes in an ID and a parent module as parameters, contains the export
object:
function Module(id = '', parent) { this.id = id; this.path = path.dirname(id); this.exports = {}; this.parent = parent; updateChildren(parent, this, false); this.filename = null; this.loaded = false; this.children = []; };
The updateChildren
method scans through the file path until the root of the file system is reached. Its job is to update the children
property of the Module
object with the new parent
, as the case may be. Here is the signature below:
function updateChildren(parent, child, scan) { const children = parent && parent.children; if (children && !(scan && children.includes(child))) children.push(child); }
Let’s see an example to understand this better. In the b.js
file above, add this line of code to print the module and the argument object:
console.log(module, arguments);
After running node b.js
, we get the following output:
retina@alex es-modules in Node % node b.js My name is Alex. undefined I'm 25 years old. undefined <ref *1> Module { id: '.', path: '/Users/retina/Desktop/es-modules in Node', exports: {}, parent: null, filename: '/Users/retina/Desktop/es-modules in Node/b.js', loaded: false, children: [ Module { id: '/Users/retina/Desktop/es-modules in Node/a.js', path: '/Users/retina/Desktop/es-modules in Node', exports: [Object], parent: [Circular *1], filename: '/Users/retina/Desktop/es-modules in Node/a.js', loaded: true, children: [], paths: [Array] } ], paths: [ '/Users/retina/Desktop/es-modules in Node/node_modules', '/Users/retina/Desktop/node_modules', '/Users/retina/node_modules', '/Users/node_modules', '/node_modules' ] } [Arguments] { '0': {}, '1': [Function: require] { resolve: [Function: resolve] { paths: [Function: paths] }, main: Module { id: '.', path: '/Users/retina/Desktop/es-modules in Node', exports: {}, parent: null, filename: '/Users/retina/Desktop/es-modules in Node/b.js', loaded: false, children: [Array], paths: [Array] }, extensions: [Object: null prototype] { '.js': [Function (anonymous)], '.json': [Function (anonymous)], '.node': [Function (anonymous)] }, cache: [Object: null prototype] { '/Users/retina/Desktop/es-modules in Node/b.js': [Module], '/Users/retina/Desktop/es-modules in Node/a.js': [Module] } }, '2': Module { id: '.', path: '/Users/retina/Desktop/es-modules in Node', exports: {}, parent: null, filename: '/Users/retina/Desktop/es-modules in Node/b.js', loaded: false, children: [ [Module] ], paths: [ '/Users/retina/Desktop/es-modules in Node/node_modules', '/Users/retina/Desktop/node_modules', '/Users/retina/node_modules', '/Users/node_modules', '/node_modules' ] }, '3': '/Users/retina/Desktop/es-modules in Node/b.js', '4': '/Users/retina/Desktop/es-modules in Node' }
As shown above, we can see the module object on line 6 with all the properties, including the filename
, id
, children
, path depth, etc. Also, we can see the argument
object, which consists of the export
object, require
function, file and folder path, and the Module
(which is essentially what the wrapper function does, but it executes the code contained in a file/module).
Finally, as an exercise, we can go ahead and print the require
function in the b.js
file. To learn more about the output of the require
function, we can check the implementation in this section of the Node source code.
Note: Special emphasis should be placed on the
load
andvalidateString
methods. ThevalidateString
method, as its name implies, checks whether the passed-in module ID is a valid string. To understand the require function on a much higher level, you can check the Knowledge section of the Node documentation.
In CommonJS, modules are wrapped as functions before they are evaluated at runtime. For ES modules, code reuse provided via import
and export
binding are already created or loaded asynchronously before they are evaluated. To understand how ESM works under the hood, you can check here. Now let’s explore further 🙂
For a quick comparison, a CommonJS module goes through this phase in its lifecycle:
Resolution –> Loading –> Wrapping –> Evaluation –> Caching
This validates the fact that for CommonJS, there is no way to determine what gets exported as a module until the module is wrapped and evaluated. This is quite different for ES modules, as the imported symbols are already parsed and understood by the language before the code gets evaluated.
When the code is parsed, just before it is evaluated, an internal Module Record is created, and only after this data structure is well-formed are the files parsed and the code evaluated.
For example:
//d.mjs const check = () => { console.log('Just checking`); }; export.check = check; //e.mjs assuming they are on the same folder path import {check} from './d'
In the e.mjs
file above, Node.js parses and validates the imports before going further to execute or evaluate the piece of code. This is not the case for a CommonJS module: the exported symbols are only made known after the module is wrapped and evaluated.
This incompatibility is one of the many reasons the standard body in charge of ECMAScript intended to implement interoperability for both ESM and Node’s existing CommonJS module system.
Furthermore, the current specifier resolution does not support all default behavior of the CommonJS loader. One of the major differences is automatic resolution of file extensions and the ability to import directories that have an index file.
For example, if we do an import './directory'
from, say, a directory that has an index.js
, ES modules do not look for an index.js
file in the specified folder, as was the case in CommonJS. Instead, it throws an error. This can be fixed by passing the experimental flag --experimental-specifier-resolution=[mode]
. More details here.
Note: To customize the default module resolution, loader hooks can optionally be provided via the
--experimental-loader ./loader-name.mjs
argument to Node.js. The loaders APIs are currently being redesigned, which means they would change in the future.
Also, while import statements can reference both an ES module and a commonJS module, import statements are permitted only in ES modules. However, for loading ES modules, commonJS supports dynamic import expressions. As an addition, a require function can be constructed within an ESM using the module.createRequire()
method.
More details about interoperability with CommonJS can be found in this section of the documentation.
import()
is supported in both CommonJS and ES modules. It can be used to include ES module files from CommonJS codeimport
keyword. Directory indexes (e.g., './database/index.js'
) must be fully specifiednode: URLs
to load Node.js built-in modules, allowing built-in modules to be referenced by valid absolute URL stringsNote: Function
require
shouldn’t be used in ES modules. This is because ES modules are executed asynchronously. To load an ES module from a CommonJS module, we can make use ofimport()
.
Readers should also note that there are still some known differences between ESM and commonJS modules. For example, native modules are not currently supported with ESM imports. Also, the ES module loader has its own kind of caching system and does not rely on require.cache
found in the commonJS parlance.
Others include the unavailability of _filename or _dirname found in the commonJS module system. ESM provides other ways of replicating this behaviour with the use of import.meta.url
.
For more details about the differences between ES Modules and commonJS modules, readers can check this section of the documentation.
ES modules are no longer tagged experimental, and are now stable in terms of technical implementation as of Node version 15.3.0. This means that they are now ready for production usage. The challenge, therefore, is upon package authors, maintainers, and developers to be explicit with defining the type field in the package.json
file and other useful conventions discussed in the specifications. More details about this can be found here.
Nowadays, it is possible to use both CommonJS and ESM in one application, but with less friction. But of course, CommonJS modules need to know if the module being loaded is a CommonJS or an ES module since the latter is loaded only asynchronously.
The issues relating to dual-package hazard and ways to avoid or curtail these hazards are extensively covered in the package section of the documentation.
Also, in accordance with the ESM spec, using the import keyword does not complete the file path by default with the file name extension, as for CommonJS modules. Therefore, this should also be explicitly stated beforehand. More details can be found in the section outlining the differences between both module systems.
Prior to the introduction of the ES6 standard, there wasn’t any native implementation for organizing source code in server-side JavaScript. The community relied heavily on CommonJS module format.
Nowadays, with the introduction and API stabilization of ES modules, developers can enjoy the many benefits associated with the release specification. This article has highlighted the transition between both module systems and their interoperability.
Note that this article has not covered the loaders API since they are still experimental and will definitely change in future versions. To learn more about it please check this section of the documentation.
Tools like Babel and esm, which translate the newer syntax into code compatible with older environments, the transition becomes even easier.
In the long run, this entire draft process is an important step and paves the way for further future improvements. Let me know if you have any questions in the comment section below, or message me on my Twitter handle. Thanks for reading 🙂
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
4 Replies to "Using ES modules in Node.js"
What about the performance decrease because of the esm modules resolution? I experienced a noticeable difference in the startup between the 13.1 and 13.7 versions (around 20%). I find this huge since I’m not using this feature anywhere yet.
Hello Gergo, I haven’t seen any real life overhead in terms of performance in the new ESM resolution algorithm. Can you point me to how you got the stat you have mentioned above, and since this is not a known issue in the wide, also peeking at the issues tab in the source code (https://github.com/nodejs/node/issues?q=is%3Aissue+is%3Aopen+esm) does not point any issues relating to performance… You can learn more about the ESM algorithm here on the docs, https://nodejs.org/api/esm.html#esm_resolution_algorithm
Hi Alexander,
Thanks for checking. I couldn’t create a trustworthy benchmark, that’s why I asked. Using 13.8 I don’t see any big differences now.
So how can you _usefully_ import an ES module into a CommonJS module? You make mention of dynamic import but that’s asynchronous and can only be called inside a function which means you can’t import anything into the top level with it since no top level ‘await’.
Without an elegant solution to that basic interoperability it seems quite painful. I’m not an experienced JS developer and just getting Jasmine to run some tests against an ES module seems like an almighty undocumented ballache. I’ve spent more time trying to make sense of this than actually doing any work!