Photo by CHUTTERSNAP on Unsplash

ES Modules in NodeJS

Frank Stepanski
6 min readMay 6, 2022

Modules are the bricks for structuring applications.

Modules allow you to divide the codebase into small units that can be developed and tested independently. Modules are also the main mechanism to enforce information hiding by keeping private all the functions and variables that are not explicitly marked to be exported.

Node.js currently comes with two different module systems: CommonJS (CJS) and ECMAScript modules (ESM or ES modules).

Module System

Not all programming languages come with a built-in module system, and JavaScript had been lacking this feature for a long time.

In the browser landscape, it is possible to split the codebase into multiple files and then import them by using different <script> tags. For many years, this approach was good enough to build simple interactive websites, and JavaScript developers managed to get things done without having a fully-fledged module system.

Only when JavaScript browser applications became more complicated and frameworks like jQuery (2006-), then Backbone (2010-), and finally React (2013-) took over the ecosystem, the JavaScript community came up with several initiatives aimed at defining a module system that could be effectively adopted within JavaScript projects.

Front-end frameworks such as React, Vue, Svelte, etc. enable the developer to use ES Modules because of transpilers like Babel which compile the import/export syntax that is written torequire().

In 2015, with the release of ECMAScript 6 (also called ECMAScript 2015), there was finally an official proposal for a standard module system: ESM or ECMAScript modules. ESM brings a lot of innovation to the JavaScript ecosystem and, among other things, it tries to bridge the gap between how modules are managed on browsers and servers.

The general feeling is that ESM will eventually become the de facto way to manage JavaScript modules in both the browser and the server landscape. Though since the majority of projects are still heavily relying on CommonJS it will take some time for ESM to catch up and be #1. 🥇

CommonJS modules

CommonJS is the first module system originally built into Node.js. Node.js’ CommonJS implementation respects the CommonJS specification, with the addition of some custom extensions.

The two main concepts of the CommonJS specification:

  • require is a function that allows you to import a module from the local filesystem.
  • exports and module.exports are special variables that can be used to export public functionality from the current module. The exports variable is just a reference to the initial value of module.exports.

The syntax to import a module is:

const package = require('module-name')

In CommonJS, modules are loaded synchronously and processed in the order the JavaScript runtime finds them. This system was born with server-side JavaScript in mind and is not suitable for the client-side. This is why ES Modules were introduced.

A JavaScript file is a module when it exports one or more of the symbols it defines, being variables, functions, or objects:

// uppercase.jsexports.uppercase = (str) => str.toUpperCase()exports.a = 1
exports.b = 2
exports.c = 3

Any JavaScript file can import and use this module:

const uppercaseModule = require('./uppercase.js')
const {a, b, c} = require('./uppercase.js')
console.log(uppercaseModule.uppercase('test'))
console.log(a, b, c)

The resolving algorithm

The term dependency hell describes a situation whereby two or more dependencies of a program, depend on a shared dependency but require different incompatible versions.

Node.js solves this problem elegantly by loading a different version of a module depending on where the module is loaded from.

All the merits of this feature go to the way Node.js package managers (such as npm or yarn) organize the dependencies of the application, and also to the resolving algorithm used in the require() function.

The node_modules directory is where the package managers install the dependencies of each package. This means that, based on the algorithm, each package can have its own private dependencies.

The resolving algorithm is the core part behind the robustness of the Node.js dependency management, and it makes it possible to have hundreds or even thousands of packages in an application without having collisions or problems of version compatibility.

Dependency Hell for Developers

Every JavaScript project starts ambitiously, trying not to use too many NPM packages along the way. Even with a lot of effort on our side, packages eventually start piling up. package.json gets more lines over time, and package-lock.json makes pull requests look scary with the number of additions or deletions when dependencies are added.

The problems that can occur in using packages, in general, can be:

  • Poorly maintained packages.
  • Insufficient documentation.
  • Bloated bundle size (possible due to not implementing tree-shaking).
  • No control over changes in a package.
  • Tied into the package API.

Overall, as an engineer you should think of dependencies as liabilities not assets. The same should be thought of as the code your write as well.

We all know that using libraries will always be part of software development. What is often missed is that using libraries well is a skill that engineers should seek to cultivate.

Questions you should ask before deciding to use a package:

  • Is the library well maintained? Are issues being addressed?
  • Is the library popular? Is it recognized as a standard solution to the problem it is addressing?
  • Does the library have clear, well-written documentation?
  • Does the library have a lot of its own dependencies? Are those dependencies compatible with our project’s existing dependencies?
  • Could the package realistically introduce security flaws?
  • Could the package realistically introduce performance issues?
  • How much does the library add to bundle size?

package.json

  • Lists the packages your project depends on (lists dependencies)
  • Specifies versions of a package that your project can use using semantic versioning rules.
  • Makes your build reproducible, and therefore, easier to share with other developers.

package-lock.json

  • A dependency tree (generated during the first package install) of all the required dependencies of installed packages in package.json
  • Keeps track of the exact version of every package that is installed.
  • Without a package lock file, a package manager will resolve the most current version of a package in real-time during the dependencies install of a package
  • The package-lock.json file needs to be committed to your Git repository, so it can be fetched by other people.

ESM: ECMAScript modules

Named and Default Exports

With ESM, there are two types of exports, named and default. Named exports are explicit which allows importing multiple exports in one line.

default export can only export a single symbol: primitive or non-primitive:

export default function greeting() {
console.log('Hello, World!');
}
// in another fileimport greeting from './greeting';
greeting(); // Output: 'Hello, World!'

named exports allow sharing multiple symbols:

export const myNumbers = [1, 2, 3, 4];const animals = ['Panda', 'Bear', 'Eagle']; export function myLogger() {
console.log(myNumbers, animals);
}
// in another fileimport greeting from './app';
import myLogger from './app';

named exports allow importing/exporting in one line with optional aliasing:

const myNumbers = [1, 2, 3, 4]; const animals = ['Panda', 'Bear', 'Eagle'];function myLogger() {
console.log(myNumbers, animals);
}
export { myNumbers, myLogger as logger }// in another fileimport { greeting, logger } from './app';// or import everything
import * as Utils from './app.js'

Using ESM in Node.js

Node.js will consider every .js file to be written using the CommonJS syntax by default; therefore, if we use the ESM syntax inside a .js file, the interpreter will simply throw an error.

There are several ways to tell the Node.js interpreter to consider a given module as an ES module rather than a CommonJS module:

  • Give the module file the extension .mjs
  • Add to the nearest parent package.json a field called "type” with a value of "module"
{"name": "sample node app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
...
}

Resources

Check out these additional resources on ES Modules in Node:

--

--

Frank Stepanski
Frank Stepanski

Written by Frank Stepanski

Engineer, instructor, mentor, amateur photographer, curious traveler, timid runner, and occasional race car driver.

No responses yet