As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Moving from CommonJS to ECMAScript Modules (ESM) is one of the most significant changes in modern JavaScript. It's not just about swapping one syntax for another. It changes how code is structured, loaded, and shared. I've helped teams through this shift, and it often feels confusing at first. The goal here is to make it simple. Let's walk through some practical ways to handle this change, step by step.
Think of CommonJS as the old way. You use require() to pull in a module and module.exports to send something out. It's dynamic, meaning it happens while your code is running. ESM is the new standard. You use import and export. It's static, meaning the structure of your dependencies is known before the code runs. This allows for better optimization and tooling.
The first step is understanding that you don't have to change everything overnight. A hybrid approach is not only possible but often necessary. You can have some files using .cjs (CommonJS) and others using .mjs (ESM) in the same project. Node.js can handle both, but there are rules.
A key technique is using conditional exports in your package.json. This tells different tools or environments which version of your code to use. It’s like having a signpost that points people in the right direction. Here's how it can look:
{
"name": "my-utility-package",
"version": "2.0.0",
"main": "./dist/cjs/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
}
}
}
In this setup, if a tool uses require() (CommonJS), it gets the file at ./dist/cjs/index.js. If it uses import (ESM), it gets ./dist/esm/index.js. The main field is a fallback for older tools that don't understand the exports map.
Let's create those two different files. First, the CommonJS version. It's straightforward.
// dist/cjs/index.js
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}
module.exports = {
calculateTotal,
formatCurrency
};
Now, the ESM version. Notice the different syntax.
// dist/esm/index.js
export function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
export function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}
// A default export for convenience
export default {
calculateTotal,
formatCurrency
};
With this setup, your package supports both worlds. The next challenge is when you have code that needs to use modules from both systems. This is called interoperability.
A CommonJS file can load an ESM module, but only asynchronously using import(). This is a function that returns a promise. It can feel awkward if you're used to synchronous require() calls.
// legacy-app.cjs - A CommonJS file needing to use a new ESM module
async function bootstrapApp() {
// We must use 'await' because import() is asynchronous
const modernModule = await import('./dist/esm/analytics.mjs');
const data = [ { price: 10 }, { price: 20 } ];
const total = modernModule.calculateTotal(data);
console.log(modernModule.formatCurrency(total)); // "$30.00"
}
bootstrapApp().catch(console.error);
The reverse is more straightforward. An ESM module can import a CommonJS module quite easily, often using a default import.
// modern-app.mjs - An ESM file using an old CommonJS module
import legacyModule from './dist/cjs/old-library.cjs';
console.log(legacyModule.doSomethingLegacy());
However, I've found this can be tricky with more complex modules. Sometimes the CommonJS module doesn't have a clean default export. In those cases, you might use a compatibility layer or a wrapper file.
Let's talk about a real problem: the "dual package hazard." Imagine you publish a package with both CommonJS and ESM entry points. If a user's application ends up loading both versions, you might get two separate instances of the same module. This can break things like singletons or shared state.
To prevent this, you can structure your code so a shared instance is always used, regardless of how the module is loaded.
// src/shared-state.js
let globalCache = null;
export function getCache() {
if (!globalCache) {
globalCache = new Map();
console.log('Cache initialized');
}
return globalCache;
}
// This ensures the same instance is used when imported via CommonJS
if (typeof module !== 'undefined' && module.exports) {
module.exports = { getCache };
}
When you're converting a large codebase, you need a strategy. I recommend starting at the leaves of your dependency tree. Convert modules that have no internal dependencies first. These are the safest. Then work your way up to the root.
Automated tools can help with the syntax change. A codemod can rewrite require() to import and module.exports to export. But be careful. These tools can't always understand the runtime behavior of your code. Always review the changes and run your tests.
Speaking of tests, you need to test both paths. Your test runner should execute your code in both CommonJS and ESM contexts. For Jest, this might mean having separate configurations or using the --experimental-vm-modules flag. It's extra work, but it prevents surprises later.
Your build tools need attention too. Bundlers like Webpack and Rollup need to know how to handle mixed module types. Here is a basic Webpack configuration that can output both a CommonJS bundle and an ESM bundle.
// webpack.config.js
const path = require('path');
const commonConfig = {
entry: './src/index.js',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
};
module.exports = [
// ESM Build
{
...commonConfig,
experiments: { outputModule: true },
output: {
path: path.resolve(__dirname, 'dist/esm'),
filename: 'index.js',
library: { type: 'module' }
},
},
// CommonJS Build
{
...commonConfig,
output: {
path: path.resolve(__dirname, 'dist/cjs'),
filename: 'index.js',
library: { type: 'commonjs' }
},
}
];
For simpler projects, you might use Node.js directly without a bundler. The file extension matters. Use .mjs for ESM files and .cjs for CommonJS files. This removes all ambiguity for Node.js. Alternatively, you can set "type": "module" in your package.json. This tells Node.js to treat all .js files as ESM. Then, you'd rename any CommonJS files to .cjs.
Circular dependencies are handled differently. In CommonJS, you can get into a situation where a module is partially loaded. In ESM, bindings are "live." An exported variable in one module will reflect changes made to it from another module that imported it. This is more predictable but means you need to be mindful of how you structure interdependent code.
Often, the best solution is to refactor to remove the circular dependency. If you can't, using dynamic import() can help break the synchronous cycle.
// module-a.mjs
export let sharedValue = 'initial';
// Later, dynamically import module-b to avoid a sync cycle
import('./module-b.mjs').then((moduleB) => {
moduleB.modifySharedValue();
});
// module-b.mjs
import { sharedValue } from './module-a.mjs';
export function modifySharedValue() {
sharedValue = 'modified'; // This changes the live binding in module-a
}
For developers, one of the biggest day-to-day changes is that __dirname and __filename don't exist in ESM. Instead, you use the url module.
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
It's more verbose, but you get used to it. You can put this in a utility function if you use it often.
Finally, remember the human element. This transition affects your team's workflow. Document the patterns you choose. Make sure everyone knows whether to use .mjs or set the package type. A consistent approach prevents confusion.
The move to ESM is about the future of JavaScript. It brings static analysis, better tooling integration, and alignment with how browsers work. By using these techniques—conditional exports, interoperability patterns, incremental migration, and updated tooling—you can navigate the change without breaking what works today. Start small, test thoroughly, and you'll find the new patterns become second nature. The initial effort pays off in a more maintainable and modern codebase.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)