We can also think of these as module specifications
. The question here is, what are the systems that Javascript uses to import and export code from various files and packages, and how are they different?
The Quick and Dirty Geography of JS Modules
Here’s a short guide to what the syntax for the two major types of module resolution systems look like to orient ourselves in the modern (2025) context.
A) ES Modules, AKA ECMAScript module (ESM) - The official Javascript module system
import/export
- used by modern browser code.
- require filename extensions in import paths
// Export
export function greet(name) return `Hello, ${name}!`
// Importing (in another file)
import { greet } from './greetings.js';
B) CommonJS
require/module.exports
// Export
function greet(name) return `Hello, ${name}!`
module.exports = { greet };
// Import (in another file)
const { greet } = require('./greetings.js');
C) AMD (Asynchronous Module Definition) Don’t worry about this one, as it’s very old.
Using ESM in Node.js
So between ESM and CJS, ESM is the more modern of the two, so why not use ESM in node? Well, because ESM isn’t enabled in node by default. But it can be done and often is!
There are a few ways to do it:
**A) Use .mjs
file extension instead of .js
**
B) Add "type":"module"
in the package.json
config to maje all js.
files in that package use ESM. Then use cjs
to make some files default back to CJS.
Once you opt in with one of these two methods, you get
- import/export syntax
- top level away
- dynamic imports
Using CJS packages in ESM
We can sort of do
Interoperability between ESM and CommonJS Modules
Easy stuff:
- CJS modules can be imported into ESM easily
Gotchas:
- When importing ESM modules into CJS, we must use dynamic import
// Must use dynamic import
const esmModule = await import('./esm-module.mjs');
- Because CJS doesn’t allow top-level await, we can’t import CJS modules in ESM that are written with top-level await
__dirname
and__filename
: These don’t exist in ESM, but we can recreate them:
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
And in general, some older CJS packages won’t be ESM compatible.
What are Dynamic Imports?
Dynamic imports allow you to load modules conditionally or on-demand using the import()
function, which returns a promise.
// Regular static import (evaluated at parse time)
import { something } from './module.js';
// Dynamic import (evaluated at runtime)
async function loadModule() {
if (someCondition) {
const module = await import('./conditionalModule.js');
module.doSomething();
}
}
This syntax can be used in CJS and ESM, with some differences. They are native to ESM, but Node added them to CJS.
- We can dynamically import ESM modules from CommonJS, which can’t be done with the standard
require()
method
Dynamic imports can allow for the code-splitting
pattern - i.e. only loading imports when they are needed, or modules whose paths are determined at runtime.
Exporting a Package to support CJS and ESM
There are couple ways to do this. This is the simplest one:
{
"name": "my-package",
"type": "module",
"exports": {
"import": "./index.js",
"require": "./index.cjs"
}
}
But it’s also possible to make a copy of your .js
file with the .cjs
extension and re-export, like so:
// index.cjs
const esmModule = require('./wrapper.cjs');
module.exports = esmModule;
Lastly, a strategy exists in which we use the (sort of esoteric) Proxy object to intercept our use of an import and use dynamic imports to get that module, if we have to:
// wrapper.cjs
let exportedModule;
async function init() {
const module = await import('./index.js');
exportedModule = module;
return module;
}
const promise = init();
module.exports = new Proxy({}, {
get(target, prop) {
if (exportedModule) return exportedModule[prop];
return promise.then(m => m[prop]);
}
});
Bonus Section: Proxies in JS
The proxy object is essentially a wrapper for any object that can be used in the place of the original object but which may redefine fundamental object operations like getting or setting.
ESM Binding
ESM imports provide references to read-only references (think const
?) of the actual variable in the exporting module. CJS, on the other hand, actually makes copies.
This generally means:
- ESM can be more memory efficient because of reference re-use rather than many many copies
- CJS allows for mutated values, since they are NOT read-only references, but rather, mutable copies of the exports.
Other Notable Differences
ESM
- supports top-level await
- Creates bindings that point to the original memory location
CJS
- Creates new objects for each module when exported
What is a Javascript Module?
I’ve been writing javascript in some capacity for almost a decade, and I don’t think I have a great answer to this.
A javascript module is a file-based scope for some executable javascript.
In javascript, a module is:
- File based - there is a one to one relationship between modules and the files that define them
- Lexical scope - A module has its own scope
- Execution Context - when a module is imported, its code is executed and exports cached.
How to use Modules
In The Browser
- ESM Is natively supported and is almost always what will be used
- CommonJS doesn’t natively work in browsers, but can be used with bundlers (like webpack), although this is sort of an older pattern at this point, and harks from a time before ESM modules existed natively
In Node
Reading
Navigating the module maze: history of JavaScript module systems
- Mentions ESM and CJS
- Asks “what is a module?”
- Talks about Revealing Module Pattern (which I had never heard of) Revealing Module Pattern
CommonJS
- Synchronous
- Doesn’t work in the browser
- Includes a Module Specification
var someModule = require('someModule')
exports = {}
AMD
- “Asynchronous Module Definition”
- Asynchronous
- Designed for a browser environment where network connection is less dependable and you may want to load multiple dependencies at once
- A competing specification for modules, usually used in the browser
RequireJS
- Probably most popular implementation of AMD
- Implements the AMD API (but wants to keep the spirit of CommonJS)
- Async
- Offers CommonJS wrappers so that CommonJS modules can be directly imported for use with RequireJS
Browserify
Allows you to ‘require’ modules in the browser by bundling up all of your dependencies.
- Open source
- Javascript bundler
- Allows devs to write Node.js-style modular code and use it in the browser
- Kind of old…? Is it still relevant?
- Supports “transforms” which are kinda cool
- Webpack and Rollup competed with it back in the day
Native ES Modules (EMS)
- Standardized into ECMAScript in 2015
- import and export
- Browser support
- Also support in Node.js
- Future proof (part of JS standard)
- Is synchronous but has an optional
import
asynchronous option!
Revealing Module Pattern
They use IIFEs (immediately invoked function expressions):
(function(){})()
Note to me: if that’s hard to remember, just note that it’s a normal function wrapped wrapped in an invoked closure: ()()
. There are at least two anatomical mnemonics that may help encode the immediacy part.
So his creates a local scope for all variables and methods. Only public methods will have access to the code inside an IIFE.
These are so cool. Here’s an example @Rahulx1 on medium.) gives:
var namesCollection = (function() {
// private members
var objects = [];
// Public Method
function addObject(object) {
objects.push(object);
printMessage(object);
}
// Private Method
function printMessage(object) {
console.log("Object successfully added:", object);
}
// public members, exposed with return statement
return {
addObject: addObject,
};
})();
Only the methods we want are returned! Wild! By the way, it’s okay to put the return statement at the top of the file. This…made no sense to me, so I investigated.
I actually tried this, and it didn’t work. But it did work if I put the return statement below this line:
var objects = []
This seems to be due to something called function hoisting
, in which the interpreter function declarations to the top of their scope before code execution. This means that you can call a function before it is declared in your code without encountering an error!
Okay definitely learning some new things here. Check this out Function Declarations vs. Function Expressions & Hoisting
Validity Qualification!
This article was written a while ago and a few of the examples here don’t actually work quite as expected, but this is probably because they are using intentionally weird syntax that probably shouldn’t work, and his been weeded out.
This actually taught me something much more important, which is the different between function declarations
and function expressions
Function declaration:
function foo(){}
Function expression:
const bar = function(){}
// or
const bar = () => {}
This gives an error:
console.log(hoist())
const hoist = () => "moose"
Cannot access ‘hoist’ before initialization. I tested this in
ts
andjs
just to be sure.
This does not:
console.log(hoist())
function hoist(){
return "hoist"
}
I was getting some mixed messages about which is called an expression
and which is called a declaration
so I called up my old friend MDN and she confirmed.
What I finally understand now is that arrow functions are a (relatively) more modern version of traditional function expressions. They’re nice because now function declarations and expressions look super different.
It turns out, declarations are loaded when the code is compiled, not when it’s executed, whereas expressions seem to be loaded and executed in the same step.
Misc Claude Convo to pull from a bit:
https://claude.ai/share/41e30fbe-463d-4438-b10f-f487aebe81a9
Dependency Patterns
This may not actually fit in here, but I wanted to cover some ways that packages depend on one another
A) normal dependencies (production) B) dev dependencies C) peer dependencies