What Is ESM? ES Modules Explained
ESM stands for ECMAScript Modules. It is the module system built into the JavaScript language itself — the one that uses import and export. Every browser supports it, Node supports it, and it is the format TypeScript targets by default in any modern setup.
So what is ESM in practice? It is the replacement for CommonJS, the require() system Node shipped with in 2009 and that npm was built on. Both still exist, both still work, and the seams between them are where most module errors come from.
This page covers what ESM is, how it differs from CommonJS, what .mjs and .cjs actually mean, and which tsconfig settings decide what TypeScript emits.
What Is ESM Compared to CommonJS
The syntax difference is the part you see first.
// CommonJS — Node's original module system
const { readFile } = require('node:fs/promises')
function loadUser(id) {
return readFile(`./users/${id}.json`, 'utf8')
}
module.exports = { loadUser }// ESM — the language standard
import { readFile } from 'node:fs/promises'
export function loadUser(id) {
return readFile(`./users/${id}.json`, 'utf8')
}Shorter, but that is not the point. The real difference is when the work happens.
CommonJS resolves modules at runtime. require() is just a function call — you can put it inside an if, build the path from a variable, call it in a loop. Node reads the file, runs it, and hands you back whatever module.exports ended up as.
ESM resolves modules before any of your code runs. The engine parses every file, builds the full dependency graph, links the bindings, and only then starts executing. import is not a function call. It is a declaration, and it has to sit at the top level of the file.
That one change is what makes tree-shaking, static analysis, and circular-import handling work properly. A bundler can tell which exports you never touched without running a line of your code. It is also why you cannot conditionally import something the way you could conditionally require it. For that case ESM gives you import(), which returns a promise and is allowed anywhere.
// Dynamic import — the ESM escape hatch, and it is async
const { renderChart } = await import('./chart.js')The other thing worth knowing is where ESM came from. CommonJS was invented for Node because the language had no module system at all; ESM was added to the language in ES2015 so that browsers and servers could finally agree on one. That is why a browser runs it natively with nothing more than an attribute:
[object Object]No bundler, no build step, no loader library. The same import statement you write for Node works in a browser tab, which is exactly what CommonJS could never do — require() was a Node API, not a language feature, and shipping it to a browser meant bundling it first.
What Is an .mjs File?
An .mjs file is a JavaScript file that Node always treats as ESM, regardless of any other configuration. Its sibling .cjs is always CommonJS.
The extensions exist because Node needed a way to tell the two apart in a codebase that already had millions of .js files meaning CommonJS. A plain .js file is ambiguous, so Node resolves it by looking at the nearest package.json:
{
"name": "my-app",
"type": "module"
}With "type": "module", every .js file in that package is ESM. Without it (or with "type": "commonjs"), every .js file is CommonJS. The explicit extensions override it either way:
| File | Module system |
|---|---|
.mjs | Always ESM |
.cjs | Always CommonJS |
.js | Depends on "type" in package.json |
.mts | Always ESM (TypeScript) |
.cts | Always CommonJS (TypeScript) |
.ts | Depends on "type" in package.json |
TypeScript mirrors the whole scheme with .mts and .cts, which compile to .mjs and .cjs. If you have met the .tsx extension, this is the same idea: the extension is a signal to the compiler, not a different language.
What Actually Changes at Runtime
Beyond syntax, four behaviours differ in ways you will notice.
Imports are live bindings, not copies. When a CommonJS module exports a value, you get a snapshot. When an ES module exports one, you get a view of the original variable.
// counter.mjs
export let count = 0
export function increment() {
count++
}// main.mjs
import { count, increment } from './counter.mjs'
console.log(count) // 0
increment()
console.log(count) // 1 — the binding updated, no re-import neededDo that with require and the second log still prints 0.
Top-level await works. ES modules are asynchronous by design, so you can await at the top level of a file without wrapping everything in an async IIFE.
// config.mjs
const response = await fetch('https://example.com/config.json')
export const appConfig = await response.json()Specifiers need file extensions. In Node's ESM resolver, ./utils does not resolve — you have to write ./utils.js. CommonJS guessed at extensions for you; ESM does not. This is the single most common reason a working project stops working the moment you flip "type": "module", and it surfaces as TS2307: Cannot find module.
There is no __dirname or require. Those are not globals — they are variables Node injects into the wrapper function it runs every CommonJS file inside. ES modules have no wrapper, so they are simply absent. In ESM you use import.meta.url, which gives you the current file's URL, and convert it with fileURLToPath when you need a real path.
TypeScript and ESM: The Settings That Matter
TypeScript does not pick a module system for you. Four tsconfig options decide what it reads and what it writes.
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
}
}module controls the output format. "nodenext" tells TypeScript to follow Node's own rules — extension and "type" field included — and emit ESM or CommonJS per file. If you are shipping through a bundler instead, "esnext" with "moduleResolution": "bundler" is the right pair, and the bundler handles the rest.
moduleResolution controls how import specifiers are looked up. Mismatching it with module is how projects end up with imports that type-check but fail at runtime.
esModuleInterop patches over the fact that CommonJS has no real default export. Without it, import express from 'express' fails against a CJS package even though that is exactly how the package is meant to be used. Leave it on.
verbatimModuleSyntax stops TypeScript from quietly deleting imports it believes are type-only. This matters more than it sounds, because a deleted import also deletes a module's side effects. With the flag on, TypeScript emits what you wrote, and you mark type imports yourself:
// models.ts — a file becomes a module the moment it exports something
export interface EsmUserRecord {
id: number
displayName: string
}
export type EsmUserId = EsmUserRecord['id']
export const buildEsmUser = (
id: EsmUserId,
displayName: string,
): EsmUserRecord => ({ id, displayName })Consumers then split the two kinds of import explicitly:
import { buildEsmUser } from './models.js'
import type { EsmUserRecord } from './models.js'✅ The import type line is erased at compile time, and everyone reading the file knows it will be.
❌ A bare import { EsmUserRecord } leaves the compiler guessing, and under isolatedModules it is an error.
The Interop Traps
Mixing the two systems is where the time goes. Three failures cover most of it.
Importing a named export that CommonJS never exported. A CJS package assigns one object to module.exports. Node can often infer named exports from it, but not always — and when it cannot, you get TS2305: Module has no exported member. The fix is to default-import the whole thing and destructure afterwards:
// ❌ the named export may not exist at runtime
import { formatDistance } from 'some-cjs-package'
// ✅ works against any CommonJS module
import pkg from 'some-cjs-package'
const { formatDistance } = pkgConsuming an untyped CommonJS package. Older packages ship JavaScript with no declaration file and no @types entry, which produces TS7016: Could not find a declaration file for module. Install the @types package if one exists, or write a one-line declare module shim.
Requiring ESM from CommonJS. This one is not fixable by configuration. require() is synchronous, ES modules load asynchronously, so a CJS file cannot require an ESM file. Newer Node versions relax this for modules without top-level await, but the reliable answer is dynamic import() — which means the calling function has to become async.
The direction matters: ESM can import CommonJS. CommonJS importing ESM is the hard one. That asymmetry is why the ecosystem has moved one way and not back.
Which Should You Use?
For anything new, ESM. It is the standard, it is what bundlers optimise for, it is what new packages publish, and an increasing number of popular libraries are ESM-only. Set "type": "module", set module and moduleResolution to nodenext, and write extensions on your relative imports from day one.
For an existing CommonJS codebase, the honest answer is that migration costs more than the syntax suggests. Every relative import needs an extension, every __dirname needs replacing, and any CJS consumer of your package breaks. If it works and nothing you depend on has gone ESM-only, "later" is a legitimate answer.
Whatever you pick, pick it once per package and let the config say so. Most module errors are not really about ESM or CommonJS — they come from a project that has quietly been told it is both. If you want to see which module settings your compiler version defaults to, the TypeScript 5.9 release notes are a good place to start, and the utility types reference covers the type-level tools you will reach for next.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges