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.

// CJS

// a.js
module.exports.a = 'a';

// b.js
const { a } = require('/a')
// ESM

// a.js
export const a = 'a'

// b.js
import { a } from './a.js'

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.

// a.cjs
module.exports = { a: 'a' }

// b.mjs
import mod from './a.cjs'
console.log(mod) // prints { a: 'a' }

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

// a1.js
module.exports.a = 'a'

// a2.js
module.exports = 'a' 

// b.js
const mod = require('/a1')
console.log(mod)
// prints '{ a: 'a' }' 

const mod = require('/a2')
console.log(mod)
// prints 'a'
// ESM

// a.js
export const a = 'a'
export default a

// b.js
import * as mod from '/a.js'
console.log(mod)
// prints '{ a: 'a', default: 'a' }' 

import mod2 from '/a.js'
console.log(mod2)
// prints 'a'

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.

// a.mjs 
export const a = 'a'
export default a

// b.cjs
const mod = require('/a');
console.log(mod) // What happens here? IT BREAKS

// b.mjs (To put it in perspective)
import mod from './a.js'
import * as mod2 from './a.js'
console.log(mod) // prints 'a'
console.log(mod2) // prints { default: 'a', a: 'a' }

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:

import code from 'lottery-codes'
console.log(code.generate()) // the lucky numbers to the jackpot
// I would so Github Star this library

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.

// package.json Example how you can create a CJS file from an ESM one
...
"type": "module", // Specifies project is ESM (leave empty if CJS)
"scripts": {
	"build": "rollup index.js --file dist/index.cjs --format cjs"
	...
},
"devDependencies": {
  "rollup": "^3.28.1"
}
...

Conceptually, converting CJS to ESM is relatively straightforward since ESM’s features is a superset of CJS’s.

// a.cjs
module.exports.a = 'a'

// dist/a.mjs
export const a = 'a'

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.

// a.mjs
export const a = 'a'
export default a;

// -> dist/a.cjs
module.exports = { a: 'a', default: 'a' }

// b.cjs
const mod = require('./dist/a');
console.log(mod) // prints { a: 'a', default: 'a' }

// b.mjs
import mod from './dist/a.cjs'
console.log(mod)
// prints { a: 'a', default: 'a' }

import mod2 from './a.mjs'
console.log(mod2)
// prints 'a' !!! **Note that it's different**

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.

// package.json (if written in ESM)
{
	"name": "lottery-codes",
	**"type": "module", // <- specifies your project as ESM**
	"types": "./dist/index.d.ts",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}
// package.json (if written in CJS)
{
	"name": "lottery-codes",
****	"types": "./dist/index.d.ts",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

<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.

In your package.json:
...
"exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
		"./helpers": {
			"import": "./dist/helpers.mjs",
      "require": "./dist/helpers.js",
      "types": "./dist/helpers.d.ts"
		}
  }
...

// Now you can do this
import main from "lottery-codes" // imports /dist/index.mjs
import helpers from "lottery-codes/helpers" // imports /dist/helpers.mjs

const main = require("lottery-codes") // imports /dist/index.js
const helpers = require("lottery-codes") // imports /dist/helpers.js

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.

menu