JavaScript started as a simple language used to make static websites more dynamic and interactive. However, projects written in JavaScript began getting increasingly complex a long time ago. Because of that, it quickly became apparent that we needed a way to break the code into smaller, manageable pieces. Throughout the years, people have had many different ideas on how to implement splitting JavaScript code into modules. In this article, we compare the most popular ones: ECMAScript Modules (ESM) and CommonJS.
CommonJS
The creator of Node.js knew that code needed to be organized into reusable modules. However, JavaScript did not have an official module system when Node.js was first presented in 2009. Because of that, Node.js introduced CommonJS modules.
Creating and exporting a module
In Node.js, every file is a separate module. To start working with CommonJS, let’s create a new file with a simple function.
sum.js
1 2 3 4 5 |
function sum(numberOne, numberTwo) { return numberOne + numberTwo; } module.exports = { sum }; |
In every file, the module variable represents the current module. We can use module.exports to make the sum function available for other modules to import and use.
Importing a module
To import a module, we need to use the require function and provide the correct path.
index.js
1 2 3 |
const { sum } = require('./sum'); console.log(sum(1, 2)); // 3 |
CommonJS also supports the import function, which allows us to import modules asynchronously.
index.js
1 2 3 4 |
import('./sum.js') .then(({ sum }) => { console.log(sum(1, 2)); // 3 }) |
ECMAScript Modules
The JavaScript language received a significant upgrade known as ES6 or ECMAScript 2015. Among other features, it included the official syntax for module management, ECMAScript Modules (ESM).
Creating and exporting a module
We have to use the export keyword to expose various values from a module.
sum.js
1 2 3 4 5 |
function sum(numberOne, numberTwo) { return numberOne + numberTwo; } export { sum }; |
Using the above syntax, we can export as many values as we want. Besides that, modules can also contain a single default export.
subtract.js
1 2 3 4 5 |
function subtract(numberOne, numberTwo) { return numberOne - numberTwo; } export default subtract; |
Importing and exporting modules
To import ECMAScript modules, we have to use the import keyword.
index.js
1 2 3 4 5 |
import { sum } from './sum.js'; import subtract from './subtract.js'; console.log(sum(3, 2)); // 5 console.log(subtract(3, 2)); // 1 |
Please notice that we use a slightly different syntax to import a default export. What’s important is that the name we use with default imports does not have to match the default export.
1 |
import sum from './subtract.js'; |
This is one of the reasons some people prefer not to use the default exports.
ESM also supports the import function, which allows us to import modules asynchronously.
index.js
1 2 3 4 |
import('./sum.js') .then(({ sum }) => { console.log(sum(1, 2)); // 3 }) |
Support for the ECMAScript Modules
While ECMAScript Modules were introduced around 2015, it took the community a while to catch up. However, now we can use ESM even natively with browsers.
If you want to know more about native JavaScript modules, check out Understanding native JavaScript modules
Bundlers such as Webpack started supporting ECMAScript Modules many years ago. They take our code that uses ESM across multiple JavaScript files and produce a single-file output. It can also split the bundle into more than one file to improve the performance.
Node.js started experimeting with support for ESM around version 8.5.0. To use it back then, we had to include the --experimental-modules, though. In version 13.2.0, they removed the need to use the flag when using Node modules. However, using ESM in Node.js still resulted in a warning in the terminal saying that the feature is experimental. This warning does not appear since the release of 14.0.0 in April 2020. To use ESM with Node.js, we can add "type": "module" to the package.json file.
What’s interesting is that the implementation of ESM in Node.js might be slightly different if you’re used to working with Webpack, for example. We must provide the full path of the module we are importing, including the file extension.
ECMAScript Modules in TypeScript
TypeScript started supporting the ECMAScript Modules syntax back in TypeScript 1.5 in 2015. What’s crucial to understand is that it transpiles our code to use CommonJS under the hood by default.
index.ts
1 2 3 |
import { sum } from './sum'; sum(1, 2); |
When we transpile the above file to JavaScript with the default configuration, we can see CommonJS.
index.js
1 2 3 4 |
"use strict"; exports.__esModule = true; var sum_1 = require("./sum"); (0, sum_1.sum)(1, 2); |
TypeScript adds the __esModule flag to indicate that the file was compiled from ESM to CommonJS.
To use ESM with TypeScript and Node.js, we need to modify our tsconfig.json file slightly.
tsconfig.json
1 2 3 4 5 6 7 |
{ "compilerOptions": { "strict": true, "module": "NodeNext", "outDir": "dist" } } |
Thanks to using NodeNext, TypeScript will use ESM imports and exports instead of CommonJS. It’s crucial to remember that Node.js requires us to provide full paths to modules we import. When working with TypeScript that we want to run in a Node.js environment, we have to do that as well.
What might seem counterintuitive is that we have to provide the path that contains the .js extension even though we are writing TypeScript code.
index.ts
1 2 3 |
import { sum } from './sum.js'; console.log(sum(1, 2)); |
When we do all that, we can have a TypeScript application that runs in Node.js and uses ECMAScript Modules under the hood.
Managing dependencies
The issue of choosing between CommonJS and ECMAScript Modules gets more complicated when dealing with dependencies.
Projects that use ECMAScript Modules can use CommonJS modules using the import syntax. However, projects that use CommonJS can’t import modules that use only ESM in any way other than through the asynchronous import function. Because of that, many developers who create JavaScript libraries written with ECMAScript decide to ship both CommonJS and ESM code. This allows their libraries to be compatible with either module system.
However, not all developers want to deal with the hassle of publishing packages that work both with CommonJS and ESM. It is increasingly popular to ship only an ESM-compatible version of a library. Because of that, we need to be aware of how both CommonJS and ESM work and what their limitations are.
Summary
In this article, we’ve gone through how CommonJS and ECMAScript Modules work. We learned their syntax and how to use them with Node.js and TypeScript. Since not all developers want to go through the trouble of publishing packages that work both with CommonJS and ESM, it gets more and more important to understand the difference between those two module systems. Thanks to doing that, we are better equipped to choose the right module system for our project and adapt to any challenges related to it.