CJS vs ESM
Aug 16, 2023
Hopefully as you read this, the world peace has been achieved by bun in destroying the divide between CJS and ESM. There is harmony in the web dev world, and people are no longer picking fights with strangers over how Javascript imports code. Sharing code between a NestJS project (CJS only) and a SvelteKit Project (ESM only) within a monorepo just magically works and doesn’t totally break everything.
But let’s not kid. This will likely never get fixed because we hate ourselves. So what’s the difference between CommonJS (CJS) and ESM (ECMAScript modules)?
Similarities and Differences
Both are literally just ways of importing and exporting code into other files. CJS came first, implemented in Node (the runtime that let’s us run JavaScript outside of the browser), before JavaScript natively implemented importing with ESM.
Looks pretty much the same right? Notice one subtlety that ESM requires specifying the file extension (here .js
). You might be wondering, “hey, my code is CJS but I use imports
and exports
!” If you’re using something other than plain node
to run your code (very likely if you’re using Typescript or maybe a framework), it’s probably converting your ESM-like code into CJS keywords under the hood.
CJS and ESM are not necessarily mutually exclusive (but honestly don’t try this). You can technically import CJS files into ESM files.
The behavior makes sense, and ESM can handle it. However, the inverse is not true: you can’t import ESM into CJS.
The Problem
One way CJS and ESM functionally differ is handling “default” exports.
CJS doesn’t let you export default
and export
in the same file like how ESM lets you. You kind of need to choose one. Pretty benign though right? Wrong. This is the heart of why working with these two is so hellish.
Because in ESM you can both export a default as well as other values while in CJS you can only pick one or the other, when importing ESM into CJS you run into undefined behavior.
b.cjs
can’t work!!! It can’t work because CJS can’t handle importing files that have both a default export and normal export because it was never designed to! This is why you can import CJS files into ESM and not vice versa, because the different functionality literally makes it impossible. And what’s more, you can’t just transpile b.cjs
because it’s unclear which import you want: import mod
or import * as mod
.
To make it even more clear, import * as
is not a feature available in CJS.
The “Fix”
Now say that you’re creating a library that you want to publish to npm
. You created a package that literally prints out winning lottery numbers:
Alas, you made one fatal mistake. You wrote the code in ESM (imports
and exports
you idiot)! People importing the library into a CJS file will have to just throw away their computer. No, you can’t just do that. You have to generate CJS files from your ESM files with something like rollup and distribute both! Take a look for yourself, most packages distribute both a CJS and ESM version of the code to accommodate.
.js is the CJS and .mjs the ESM
dist-cjs has the code as CJS files and dist-es, ESM files
Now this can be its own post, but essentially, you must take your CJS and/or ESM code and build it a CJS only distribution and ESM only distribution. Aren’t adding build steps so great.
Conceptually, converting CJS to ESM is relatively straightforward since ESM’s features is a superset of CJS’s.
But how do you turn ESM into CJS with the whole default export + export issue? It’s a bit of a hacky solution. Default ESM exports just get moved to default
parameter in a module.exports
.
the console.log
in b.mjs
might be surprising because you might’ve expected it to console log just the default
value ‘a’
like it would if it directly imported the a.mjs
file. This is why you really shouldn’t mix CJS and ESM when you can. You get weird behavior like this.
The package.json
So now that you’ve added a build
step that takes your code and creates both a CJS and ESM version, how do you actually expose those files for use in other places? You have to specify in your package.json
where is what.
<aside> ❗ If your project is predominantly ESM, add "type": "module"
to your package.json
and use .js
when writing ESM files, and whenever you need a CJS file use .cjs
.
If your project is mostly CJS, don’t add the type: module and use for CJS and .mjs
for ESM files.
</aside>
main
specifies which file to handoff if this package is imported into a CJS file (so it should be CJS!).
module
specifies which file if the package is imported into a ESM file (so it should be ESM!).
exports
repeats this with different syntax that might be more clear. If you try to import
the code (ESM) give one file, and if you try to require
the code (CJS) give a different one.
The cool part about exports
is that can specify multiple “entry points” into your package.
Conclusion
This post doesn’t really go into the history of the two, how they truly work, the security differences between the two, etc. This post is meant to put in crystal clear terms what the fundamental difference between the two are and how to work around it.