CommonJS vs ESM in Node

This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.

This video is available to students only
Unlock This Course

Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

Thumbnail for the \newline course Bundling and Automation in Monorepos
  • [00:00 - 00:38] In the next part of our course, we are going to be focusing on sharing code within our monorepo, which requires us to examine Node's module system in in a bit more depth. This material is going to be a bit dry, but necessary, and we need to speak about the difference between ESM and CJS code in Node.js. So what's CJS? CJS or CommonJS modules is the old style of Node code, where you would have a require statement when you wanted to import a module.

    [00:39 - 01:17] Nowadays you're not going to see much of it, but we need to be aware of how it works, especially once we get into bundling and the implications of the different file extensions that you can choose. So when a file has a .js extension, by default it's going to be treated as a CommonJS module. While for ECMAScript modules, those use the more modern syntax with import, and they have either an .mjs or .mts extension, or you need to have type: "module" set up in your package.json.

    [01:18 - 01:45] You can think about it in terms of when you see a file that has import statements, that's an ESM file. When you see a file that has required statements, that's a CJS file. The real differences, however, do go deeper. The way that scopes are done the evaluation order of exports versus import statements top level await. There's a lot of differences between the two module systems.

    [01:46 - 02:08] So how does NodeJS decide which system to use. Well if we have a distinct file extension then it's going to just use whatever the file extension specifies. It won't care even if you have an override in your package.json, what that override says—the file extension takes precedence.

    [02:09 - 02:21] For TypeScript this means that it follows the same rules if you have a .mts or .cts file. That means that TypeScript will try to match the module parsing to either ESM or CJS.

    [02:22 - 03:00] If you have a plain .js file, what Node is going to do is it's going to traverse up until it finds a package.json file, and then look for a type: "module" configuration within that file. If it's missing or if it's type: "commonjs" then it's going to treat the file as CJS. If you have type: "module", then .js files will be treated as ESM. So let's kind of quickly see what CommonJS looks like.

    [03:01 - 03:08] A CommonJS file usually will have this sort of structure. Exports are done with "module.export".

    [03:09 - 03:46] Then importing you can destructure the same way you can treat it as a variable that you can destructure from. In terms of characteristics, CJS is always synchronously loaded, so all files are synchronously resolved on the first import call. "require()" is a function call, which means that you can do a require at any point of your code. You can, for example, have a command line input, and based on the user input, you might require a separate module that will handle different inputs.

    [03:47 - 04:17] So you can do dynamic requires in this way, even though they're dynamic they're resolved synchronously. So a "require()" call always returns synchronously the result of what was required. Exports are just a mutable object. So "module.exports" is a simple JavaScript object that can be mutated several times within the same file. Global variables exist like __filename and __dirname.

    [04:18 - 04:32] They give you the current file's file name and directory. There is no native import or export statements and you cannot have top level await. So you cannot have unscoped awaits in CJS.

    [04:33 - 04:53] It does not support import statements like "import fs from 'node:fs'". It does support an "import()" function call that returns a promise for the module that's being imported. It will be treated as an ESM import, which means the default symbol must be renamed.

    [04:54 - 05:05] So you're going to get an object that has a default on it that you must rename. It's a bit weird, and this exact behavior actually depends on on the implementation of the module that you're importing.

    [05:06 - 05:11] So be careful around this. But there is a way to use imports within CJS.

    [05:12 - 05:29] And the reason why you would use an "import()" call is until Node 23, it was impossible to "require()" ESM modules from CJS. You can only require CJS and you needed to use the asynchronous imports for ESM.

    [05:30 - 05:49] Luckily, in Node.js over version 22, you can now directly do a synchronous require() of an ESM module, and as long as that module abides by some rules like not using top level await is one of those. Then this works and is fine.

    [05:50 - 05:56] You can require ESM modules from your CJS code. In Node less than version 22.

    [05:57 - 06:19] You must always wrap it in an import() call with a promise and handle that asynchronously. If we go to ECMAScript overview, usually you would just directly do named exports like "export function add() {...}" or "export default", and then do imports named imports with destructuring. This would be "add" or this would be "Calculator".

    [06:20 - 06:39] Because ESM's import/export declaration are static, you can do better analysis on the import tree and you can do better tree shaking. So that was the big promise of ESM that it would make it easier for bundlers to tree shake your code and produce smaller bundles for browsers.

    [06:40 - 07:14] When you do imports in ESM, you must specify the extension of the file being imported. This makes imports faster compared to CJS, because when you do an import in CJS, they are like depending on the environment you're working in, they can be between 4 and 7 different possible final file names that must be resolved to find the real file to import. So by forcing the exact extension, this ambiguity is removed in ESM and it actually makes imports faster.

    [07:15 - 07:30] ESM supports top level await, but you shouldn't use that in any module that's being exported for use by another module. You should only use top level await in leaf modules that only import other stuff, but are not imported themselves.

    [07:31 - 07:46] Usually this would be script files that you're writing. The globals for __filename and __dirname do not exist, and instead you have stuff like "import.meta.filename" and "import.meta.dirname" in Node over version 20.11.

    [07:47 - 08:07] Now, the reason why I'm going over this and pointing out these differences is that you need to be aware of them, because in the next lesson, we're going to go into TypeScript and bundlers behavior, which actually does not cleanly map to this. What we went over here is the behavior of Node.js itself.

    [08:08 - 08:20] And this is how things work if you're just running Node. But the moment you start running TypeScript or bundling your code, then many other considerations come into place.

    [08:21 - 08:24] So I will see you in the next lesson.