This is an article I wrote for the Toss technology blog, where I am currently employed. Original link (Korean)
In the Toss Frontend Chapter, we continuously move repeated code into libraries to improve development productivity. As a result, we are currently maintaining more than 100 libraries.
Since Node.js 12, a new module system called ECMAScript Modules (ESM) has been added, alongside the existing CommonJS module system (CJS). This means that libraries now need to support both module systems.
At Toss, we support this through the exports
field in the package.json
. Let's take a closer look at each module system and the exports
field.
Two Module Systems in Node.js
Node.js has two module systems: CommonJS (CJS) and ECMAScript Modules (ESM).
CommonJS (CJS)
// add.js
module.exports.add = (x, y) => x + y;
// main.js
const { add } = require("./add");
add(1, 2);
ECMAScript Modules (ESM)
// add.js
export function add(x, y) {
return x + y;
}
// main.js
import { add } from "./add.js";
add(1, 2);
- CJS uses
require
/module.exports
, while ESM usesimport
/export
statements. - The CJS module loader works synchronously, while the ESM module loader works asynchronously.
- Because ESM supports Top-level Await.
- Therefore while ESM can import CJS, the reverse is not possible because CJS does not support Top-level Await.
- Additionally, the two module systems have different default behaviors, making them difficult to be compatible.
Why Support Two Module Systems?
Why should we support two module systems that are challenging to make compatible? Why not just choose one? Why does Toss consider this important?
Toss actively uses Server-side Rendering (SSR), so we should support CJS for Node.js environment.
Module system support also relates to performance in the browser environment. In the browser, rapid page rendering is important and especially JavaScript is one of the resources that stops the rendering during loading.
Therefore it is important to reduce the size of JavaScript bundles, to minimize the time rendering is interrupted. To do so, Tree-shaking, the process of eliminating unnecessary and unused code, is essential.
CJS makes tree-shaking challenging, while ESM makes it more straightforward.
CJS allows to dynamically use require
/ module.exports
:
// require
const utilName = /* dynamic value */;
const util = require(`./utils/${utilName}`);
// module.exports
function foo() {
if (/* dynamic condition */) {
module.exports = /* ... */;
}
}
foo();
Therefore, CJS is challenging to apply static analysis during build time, so module relationships are only determined at runtime.
On the other hand, ESM enforces a static structure, disallowing dynamic values in import
path and restricting use of exports
to the top-level scope:
import util from `./utils/${utilName}.js`; // Not allowed
import { add } from "./utils/math.js"; // Allowed
function foo() {
export const value = "foo"; // Not allowed
}
export const value = "foo"; // Allowed
This allows ESM to perform static analysis during the build, making tree-shaking easier.
Given these considerations, Toss decided to maintain libraries that support both CJS and ESM.
Determining If a File Is CJS or ESM
With the two module systems and the need to support both, how can we determine whether a .js
file is CJS or ESM? This is done by looking at the type
field in package.json
or the file extension.
- The module system of a
.js
file is determined by thetype
field inpackage.json
.- The default value of the
type
field is"commonjs"
, interpreting.js
as CJS. - Another option is
"module"
, where.js
is interpreted as ESM.
- The default value of the
.cjs
files are always interpreted as CJS..mjs
files are always interpreted as ESM.
TypeScript also applies the same rules when the moduleResolution
option in tsconfig.json
is set to nodenext
or node16
, starting from 4.7.
- When the
type
field is"commonjs"
,.ts
files are interpreted as CJS. - When the
type
field is"module"
,.ts
files are interpreted as ESM. .cts
files are always interpreted as CJS..mts
files are always interpreted as ESM.
package.json's exports field
Now we have understood the differences between CJS and ESM, like how to set the default module system for a package, and how extensions work.
But how can a single package smoothly provide both CJS and ESM?
The answer is the exports
field. What problems does the exports
field solve, and what is its role?
Package Entry Point Specification
By default, the exports
field does the same role of the main
field in package.json
. It allows specifying the package's entry point.
Subpath Exports Support
In the previous filesystem-based approach, accessing any JS file within a package was possible, and the actual location of the files and import path could not differ.
// Directory structure
/modules
a.js
b.js
c.js
index.js
require("package/a"); // Not allowed
require("package/modules/a"); // Allowed
By using the exports
field and making imaginary subpath, the import path can differ from the actual location of the files:
// CJS package
{
"name": "cjs-package",
"exports": {
".": "./index.js",
"./a": "./modules/a.js"
}
}
// Imports ./modules/a.js, not ./a.js
require("cjs-package/a");
// Error
// ./b is not specified in the exports field.
require("cjs-package/b");
Conditional Exports Support
In the past, making a Dual CJS/ESM package was challenging due to filesystem-based operations.
With the exports
field, you can use the same import path to provide different modules based on specific conditions:
{
"name": "cjs-package",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./esm/index.mjs"
}
}
}
// In CJS environment
// Imports ./dist/index.cjs
const pkg = require("cjs-package");
// In ESM environment
// Imports ./esm/index.mjs
import pkg from "cjs-package";
Writing the Correct Exports Field
To correctly write the exports
field for a Dual CJS/ESM package, there are some points to consider:
Use Relative Paths
All paths in the exports
field must start with .
representing relative paths:
// X
{
"exports": {
"sub-module": "dist/modules/sub-module.js"
}
}
// O
{
"exports": {
".": "./dist/index.js",
"./sub-module": "./dist/modules/sub-module.js"
}
}
Use the Correct Extension Based on the Module System
When using conditional exports, you must use the correct extension based on the module system that the package follows(the type
field in package.json
):
- For CJS packages:
// ESM must specify .mjs
{
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
}
}
- For ESM packages:
// CJS must specify .cjs
{
"type": "module",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.js"
}
}
}
Not following this rule and using .js
for all extensions may lead to unexpected behaviors. Let's consider a scenario:
cjs-package
is a CJS package.- Because its
type
field is set to"commonjs"
.
- Because its
./dist/index.js
is a CJS module../esm/index.js
is an ESM module.
{
"name": "cjs-package",
"type": "commonjs",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./esm/index.js"
}
}
}
In CJS environment, requiring cjs-package
works fine because ./dist/index.js
is a CJS module and the extension is .js
, so the CJS Module Loader is used:
// Works fine.
// Imports ./dist/index.js with CommonJS Module Loader.
const pkg = require("cjs-package");
However, in ESM environment, importing cjs-package
leads to an error. Because ./esm/index.js
is an ESM module but the extension is .js
, so the CJS Module Loader is used:
// Error occurs.
// Reads ./esm/index.js with CJS Module Loader.
import * as pkg from "cjs-package";
TypeScript Support
In the past, TypeScript also works based on filesystem:
// Imports ./sub-module.d.ts
import subModule from "package/sub-module";
But starting from 4.7, the moduleResolution
option now includes node16
and nodenext
, which make it find Type Definitions based on the exports
field and distinguishes between CJS TypeScript (.cts
) and ESM TypeScript (.mts
).
- For CJS packages:
// ESM TS must specify mts
{
"exports": {
".": {
"require": {
"types": "./index.d.ts",
"default": "./index.js"
},
"import": {
"types": "./index.d.mts",
"default": "./index.mjs"
}
}
}
}
- For ESM packages:
// CJS TS must specify cts
{
"type": "module",
"exports": {
".": {
"require": {
"types": "./index.d.cts",
"default": "./index.cjs"
},
"import": {
"types": "./index.d.ts",
"default": "./index.js"
}
}
}
}
What happens if these rules are not followed? Let's consider a scenario:
esm-package
is an ESM package.- Because its
type
field is set to"module"
.
- Because its
{
"name": "esm-package",
"type": "module",
"exports": {
".": {
"types": "./index.d.ts",
"require": "./index.cjs",
"import": "./index.js"
}
}
}
In this case, attempting to require esm-package
in .cts
(CJS TypeScript) results in a type error because esm-package
only supports ./index.d.ts
, which is interpreted as ESM TypeScript:
// index.cts
// Type Error: esm-package is identified as an ES module that cannot be synchronously imported.
// This error occurs because .d.cts for CJS TypeScript is not supported.
import * as esmPkg from "esm-package";
Conclusion
Recently, libraries at Toss have been deployed with the correct exports
field. They not only support CJS/ESM JavaScript but also TypeScript seamlessly.
While the JavaScript/TypeScript ecosystem continues to evolve, libraries that support TypeScript well are still challenging to find, even among well-known ones.
So, why don't we become the starting point for this change? Toss always welcomes those who want to solve such technical challenges together. We want to contribute to building a thriving ecosystem together.
References
- About Node.js CJS/ESM:
- About the
exports
field: - About TypeScript support for CJS and ESM: