29.3 ES Modules vs CommonJS in Node.js TypeScript Projects
Right, let’s talk about the single most common source of confusion and hair-pulling in modern Node.js development: the great module schism. You’ve got these two systems, CommonJS (CJS) and ES Modules (ESM), living in an awkward, tense truce inside your Node.js project. It’s like your in-laws are visiting, and one insists on using forks while the other brought their own ceremonial chopsticks. They can coexist, but you have to be very careful how you set the table.
Node.js was built on CommonJS. require() and module.exports are its bedrock. Then JavaScript the language officially adopted a module system, ES Modules, with import and export. Node.js, being a good citizen, had to adopt this new standard, but it couldn’t just break every existing package in the universe. So now we live in a transition period, and TypeScript, brilliant tool that it is, has to bridge both worlds. The key to sanity is understanding who wants what and when.
The Two Tribes: import/export vs. require/module.exports
First, let’s be crystal clear on the syntax, because mixing them up is a classic rookie mistake that leads to baffling errors.
CommonJS (CJS): The Old Guard This is Node’s native, classic system. It’s what you’ve probably seen the most.
// cjs-module.js
const { someHelper } = require('./another-cjs-module');
module.exports = {
myFunction: () => {
console.log('I am a legacy artifact.');
}
};
It’s dynamic. You can require things inside if statements. It feels very JavaScripty, for better or worse.
ES Modules (ESM): The New Standard
This is the official JavaScript standard. It uses import and export statements.
// esm-module.ts
import { someHelper } from './another-esm-module.js'; // Note the extension!
export const myFunction = () => {
console.log('I am the future, apparently.');
};
// You can also use default exports
export default myFunction;
It’s static. The imports are hoisted. This allows for better tooling and optimization, like tree-shaking. Crucially, note the .js extension in the import—even if the source file is .ts. This is because the import path refers to the output of the compilation, not the source. TypeScript’s compiler figures this out, but it’s a mental leap you need to make.
Telling Node.js What You’re Playing With: The package.json “type” Field
Node.js needs a rule to decide how to treat any given .js file. It does this by looking for the nearest package.json and its "type" field.
{
"name": "my-project",
"version": "1.0.0",
"type": "module", // or "commonjs"
}
"type": "commonjs"(or missing): Files are treated as CommonJS. You can userequire."type": "module": Files are treated as ESM. You must useimport/export.
Here’s the kicker: this setting also changes how TypeScript emits your code. If your tsconfig.json has "module": "CommonJS" (which is still the default for Node.js targets), it will compile your beautiful import statements into require calls. If you set "module": "ESNext", it will leave the imports as-is, and you must have "type": "module" in your package.json.
The most common, and frankly safest, setup for a new Node.js project today is this:
// package.json
{
"type": "module"
}
// tsconfig.json
{
"compilerOptions": {
"module": "ESNext", // or "Node16", "NodeNext"
"target": "ES2022",
"moduleResolution": "Node16" // or "NodeNext" - CRITICAL for ESM
}
}
This tells TypeScript to output ESM syntax and tells Node.js to interpret the output .js files as ESM.
The Interop Horror (and How to Avoid It)
This is where the real fun begins. What happens when an ESM file tries to import a CJS package? Or vice versa? Node.js does its best to create interoperability, but it’s… janky.
Importing CommonJS from ESM
It mostly works. You can import a CJS module, and Node.js will try to give you what it exports.
// esm-file.ts
import _ from 'lodash'; // A CommonJS library
import { shuffle } from 'lodash'; // This might work, but it's sketchy
// The safer way is to use the default import and namespace it
import * as lodash from 'lodash';
lodash.shuffle([1, 2, 3]);
Why is it sketchy? Because CJS doesn’t have named exports in the way ESM expects. Tools like TypeScript and Node.js create a “synthetic default export” from the module.exports object. It usually works, but it’s a facade.
Importing ESM from CommonJS
This is where the wheels fall off. You cannot use require to load an ES module. It will throw an error.
// cjs-file.js
const myModule = require('./esm-module.js'); // ERROR! Cannot require an ES module
The only way to do this is by using a dynamic import() inside an async function, which is… gross.
// cjs-file.js
async function main() {
const myModule = await import('./esm-module.js');
myModule.myFunction();
}
main();
This is a mess. The moral of the story is: pick one module system for your application code and stick to it. Trying to be a mixed shop is a path to frustration. If you’re starting a new project, choose ESM. The ecosystem is finally getting there.
The File Extension Landmine
In ESM world, file paths in import statements are mandatory and must be fully specified. This is the biggest “wait, what?” moment for developers coming from CJS.
// In ESM, this is ILLEGAL. It will fail.
import { something } from './my-file';
// You MUST include the file extension for relative imports.
import { something } from './my-file.js'; // <- This works.
Yes, you have to write import ... from './my-file.js' even though you are literally looking at the my-file.ts file in your editor. Remember: the import path is for the runtime (the compiled JavaScript), not the source (TypeScript). TypeScript’s compiler is smart enough to resolve my-file.ts when it sees my-file.js in your source code. It feels wrong, but you get used to it. Always use the .js (or .cjs, .mjs) extension in your import paths.
Best Practices: Picking Your Poison
- Go All-In on ESM: For new projects, this is the way. Set
"type": "module"in yourpackage.jsonand"module": "ESNext"&"moduleResolution": "Node16"in yourtsconfig.json. Embrace the future and its annoying file extensions. - Stick with CommonJS: If you’re maintaining a large old codebase, or if a critical dependency breaks under ESM, it’s perfectly valid to stay with CJS. Set your
tsconfig.jsonto"module": "CommonJS"and just keep usingrequirein your compiled output. - Be Explicit with File Extensions: Even if you’re using CJS, start using the
.jsextension in your imports. It’s a good habit and makes a future migration to ESM infinitely easier. - Avoid Mixing: If you can, avoid having some files as ESM and others as CJS in your own application codebase. The interop is a last resort, not a design pattern.
It’s a messy transition, no doubt. The Node.js team had an impossible task, and this was the least-worst solution. By understanding the rules of the game, you can avoid the most common pitfalls and stop fighting your tools. Now go forth, and may your imports be ever resolved.