ESM vs CJS
moduleResolution & module
The three most confusing TypeScript/Node.js configuration topics explained from first principles. Understand what each setting actually controls and how they interact.
What are module systems?
JavaScript didn't have a built-in module system until ES2015. Before that, Node.js invented
CommonJS (CJS) using require(). The ECMAScript spec later
standardized ES Modules (ESM) using import/export.
Today both co-exist — and understanding the difference is critical for Node.js, TypeScript, and bundlers.
CommonJS (CJS) CJS
CommonJS is Node.js's original module system, introduced in 2009. It uses require() and module.exports.
It was never part of the JavaScript language spec — it's a Node.js convention.
// Named exports
function add(a, b) { return a + b; }
const PI = 3.14159;
module.exports = { add, PI };
// Or one at a time
module.exports.add = function(a, b) { return a + b; };
// Default export (whole object)
module.exports = function greet(name) {
return `Hello, ${name}`;
};
// Named imports (destructure)
const { add, PI } = require('./math');
// Whole module
const math = require('./math');
math.add(1, 2);
// Dynamic — can be inside a function, if block, anywhere
if (condition) {
const utils = require('./utils'); // WORKS in CJS
}
// Inline require with no caching
const value = require('./config').value;
CJS Key Characteristics
- Synchronous:
require()blocks execution until the file is loaded. This works fine on a server (filesystem is local), but not in a browser. - Dynamic: You can
require()inside loops, conditionals, functions — anywhere. - Cached: The first
require()executes the file; subsequent calls return the cachedmodule.exportsobject. - No static analysis: Bundlers can't know at build time which exports you use → can't tree-shake CJS reliably.
- Circular deps: Supported but tricky — you get a partial object if circularly required.
ES Modules (ESM) ESM
ES Modules are the official JavaScript standard (ES2015+). They use import and export keywords.
Browsers support them natively. Node.js has supported them since v12 (stable in v14+).
// Named exports
export function add(a, b) { return a + b; }
export const PI = 3.14159;
// Export list
function add(a, b) { return a + b; }
const PI = 3.14159;
export { add, PI };
// Default export
export default function greet(name) {
return `Hello, ${name}`;
}
// Re-export from another module
export { add } from './math.js';
// Named imports
import { add, PI } from './math.js'; // .js extension required in Node ESM!
// Default import
import greet from './greet.js';
// Namespace import (like import * in TS)
import * as math from './math.js';
// Dynamic import (async, returns a Promise)
const { add } = await import('./math.js'); // works anywhere
// Side-effect only import
import './setup.js';
ESM Key Characteristics
- Static: Imports must be at the top level — no dynamic paths at parse time. This enables tree-shaking and circular dependency detection.
- Asynchronous: Modules are loaded asynchronously (important for browser performance).
- Live bindings: Imported values are live references, not copies. If the exporter changes the value, your import sees the update.
- Strict mode always on: ESM is always in strict mode — no implicit globals, no
with, etc. - Top-level
await: You canawaitat the module level without wrapping in an async function. - Own
this:thisat the top level isundefined(notglobal/window).
Side-by-Side Comparison
| Feature | CJS CommonJS | ESM ES Modules |
|---|---|---|
| Syntax | require() / module.exports |
import / export |
| Standard | Node.js convention (non-standard) | ECMAScript spec (TC39) |
| Loading | Synchronous | Asynchronous |
| Dynamic imports | Yes — require() anywhere |
Async only — await import() |
| Static analysis | No — impossible to know at compile time | Yes — enables tree-shaking |
| Tree-shaking | Not reliable | Yes (bundlers) |
| Strict mode | Opt-in ('use strict') |
Always on |
| Top-level await | No | Yes |
__dirname / __filename |
Available | Not available (use import.meta.url) |
| Browser native support | No (needs bundler) | Yes |
| Node.js support | Default | Supported since v12 (with .mjs or "type":"module") |
| File extension | .js or .cjs |
.mjs or .js (when "type":"module") |
| Extension in import path | Optional (Node.js resolves it) | Required in Node.js ESM |
Interop Problems
The biggest pain point: CJS and ESM don't mix cleanly. Here's exactly what works and what doesn't.
// ESM file importing a CJS package
import express from 'express'; // ✅ OK
import { Router } from 'express'; // ✅ OK
// CJS module.exports becomes the default export
// Named exports come from the object's keys
// CJS file trying to require an ESM package
const chalk = require('chalk'); // ❌ ERR_REQUIRE_ESM
// chalk v5+ is ESM-only
// You CANNOT require() an ESM module
// Fix: use dynamic import (async)
const { default: chalk } = await import('chalk');
instanceof checks, singletons, and shared state.
ESM-only packages you can't require()
Popular packages that went ESM-only and break CJS codebases:
chalkv5+node-fetchv3+p-limitv4+orav6+execav6+gotv12+
Solutions: pin to last CJS version, use await import(), or convert your whole project to ESM.
Using __dirname in ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Or using import.meta.dirname (Node 21.2+)
const dir = import.meta.dirname;
The "type" field in package.json
The "type" field tells Node.js how to interpret .js files in that package.
This is the single most important field for controlling CJS vs ESM in Node.js.
// package.json
{
"type": "commonjs" // or omit entirely
}
// .js files are treated as CJS
// require() works in .js files
// Must use .mjs for ESM files
// package.json
{
"type": "module"
}
// .js files are treated as ESM
// import/export work in .js files
// Must use .cjs for CJS files
| File extension | With "type":"commonjs" |
With "type":"module" |
|---|---|---|
.js | CJS | ESM |
.cjs | Always CJS | Always CJS |
.mjs | Always ESM | Always ESM |
.ts | Controlled by tsconfig module setting (not package.json) | |
"type" field, .cjs is always CommonJS and .mjs is always ESM. Use these extensions to escape the ambient setting.
The "exports" field (modern package entry)
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs", // used by ESM consumers
"require": "./dist/index.cjs", // used by CJS consumers
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.cjs", // fallback for old Node / tools
"module": "./dist/index.mjs" // bundler hint (not an official field)
}
File Extensions: .js .mjs .cjs .ts .mts .cts
| Extension | Format | Use when |
|---|---|---|
.js | Depends on "type" | Default — whatever the package ambient mode is |
.mjs | Always ESM | Force ESM in a CJS package |
.cjs | Always CJS | Force CJS in an ESM package |
.ts | TypeScript source — format from tsconfig | Normal TS files |
.mts | TypeScript → compiles to .mjs | Force ESM TypeScript files |
.cts | TypeScript → compiles to .cjs | Force CJS TypeScript files |
.d.ts | Type declarations only | Generated by TypeScript |
.d.mts | ESM type declarations | Types for .mjs files |
.d.cts | CJS type declarations | Types for .cjs files |
What is module resolution?
Module resolution is the algorithm TypeScript (and Node.js) uses to turn an import string
like './utils' or 'lodash' into an actual file on disk.
The moduleResolution tsconfig option controls which algorithm TypeScript uses.
moduleResolution is about finding files. The module option (Part 3) is about what output format TypeScript emits. They're related but independent.
{
"compilerOptions": {
"moduleResolution": "bundler" // node | bundler | node16 | nodenext
}
}
moduleResolution: "node" Classic
The old default. Mimics how Node.js resolves CommonJS modules. It's the resolution algorithm most projects used for years — but it has blind spots for modern ESM packages.
How it resolves imports
import x from './utils'1. Try: ./utils.ts
2. Try: ./utils.tsx
3. Try: ./utils.d.ts
4. Try: ./utils/index.ts
5. Try: ./utils/index.tsx
6. Try: ./utils/index.d.ts
import x from 'lodash' (node_modules)1. Look in node_modules/lodash
2. Read package.json → look at "main" field
3. Try index.ts, index.d.ts
// Does NOT read "exports" field in package.json
// Does NOT require .js extensions in import paths
moduleResolution: "node" ignores the "exports" field in package.json. Modern packages that use exports for subpath exports or conditional exports won't resolve correctly.
Also: you can write
import x from './utils' (no extension) and TypeScript won't complain — but this won't work in native Node.js ESM.
moduleResolution: "bundler" Modern
Added in TypeScript 5.0. Designed specifically for projects using a bundler (Vite, webpack, esbuild, Rollup, Parcel). This is the right choice for Next.js, React, Vue, SvelteKit — any project where a bundler processes your files.
What it does differently from node
- ✅ Reads the
"exports"field inpackage.json(supports subpath exports) - ✅ Allows extensionless imports (
./utilsinstead of./utils.js) — bundlers handle resolution - ✅ Allows importing
.tsfiles directly (bundlers transpile them) - ❌ Does NOT enforce Node.js ESM rules (no extension requirement)
- ❌ Cannot be used without
module: "esnext"or"preserve"
{
"compilerOptions": {
"module": "esnext", // or "preserve"
"moduleResolution": "bundler",
"target": "es2022",
"esModuleInterop": true,
// bundler-specific: you don't need extensions
"allowImportingTsExtensions": true, // import './foo.ts'
"noEmit": true // bundler does the emitting
}
}
// All of these work — bundler resolves them
import { foo } from './utils'; // no extension
import { foo } from './utils.ts'; // .ts extension
import { foo } from './utils.js'; // .js extension
// Subpath exports work
import { Button } from 'my-ui/components'; // reads "exports" field
moduleResolution: "node16" / "nodenext" Node.js ESM
Added in TypeScript 4.7. Mirrors how Node.js natively resolves ESM modules. Required when you're writing a Node.js package that uses ESM without a bundler.
The key enforcement: explicit file extensions
// TypeScript errors — no extension
import { add } from './math';
// Error: Relative import paths need explicit
// file extensions in ECMAScript imports
// when '--moduleResolution' is 'node16'.
// Import the .js extension — even for .ts source files!
import { add } from './math.js';
// TypeScript knows math.js will exist after
// compiling math.ts → math.js
moduleResolution: "nodenext", you write import from './math.js' even though the source file is math.ts. TypeScript knows the compiled output will be math.js and accepts it.
Mixed CJS/ESM files with nodenext
// src/server.ts → compiled to .js → format depends on package.json "type"
// src/server.mts → compiled to .mjs → always ESM
// src/legacy.cts → compiled to .cjs → always CJS
// In a .mts (ESM) file — must use .js extension
import { helper } from './helper.js'; // ✅
// In a .cts (CJS) file — can use require()
const { helper } = require('./helper'); // ✅
Comparing All Resolution Modes
| Feature | node |
bundler |
node16 / nodenext |
|---|---|---|---|
| Extension required in imports | No | No | Yes (for ESM) |
Reads package.json "exports" |
No | Yes | Yes |
Reads package.json "main" |
Yes | Yes | Yes (CJS fallback) |
Supports import './foo.ts' |
No | Yes (with flag) | No |
Index file resolution (./dir → ./dir/index) |
Yes | Yes | No (ESM doesn't do this) |
| Works without a bundler (Node.js native) | Yes (CJS only) | No | Yes |
| Best for | Old Node.js CJS projects | Next.js, Vite, webpack apps | Node.js ESM packages/CLIs |
| TS version introduced | v1 | v5.0 | v4.7 |
The module compiler option
The module option in tsconfig.json controls the output format
TypeScript emits when it compiles your .ts files. It answers: "What module system
should the compiled JavaScript use?"
module= what output format do we compile to? (CJS? ESM?)moduleResolution= how do we find files when resolving imports?
module: "commonjs"
TypeScript compiles all import/export to require()/module.exports. The classic Node.js setup.
import { readFile } from 'fs/promises';
import { helper } from './utils';
export function main() {
helper();
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = void 0;
const promises_1 = require("fs/promises");
const utils_1 = require("./utils");
function main() { (0, utils_1.helper)(); }
exports.main = main;
- Use with:
moduleResolution: "node" - Best for: Node.js servers, AWS Lambda, old tooling that expects CJS
- Enables:
require(),__dirname,__filename, synchronous module loading
module: "esnext"
TypeScript preserves import/export syntax as-is, targeting the latest ECMAScript module spec. The bundler (Vite, webpack, esbuild) handles the rest.
import { readFile } from 'fs/promises';
import { helper } from './utils';
export function main() {
helper();
}
import { readFile } from 'fs/promises';
import { helper } from './utils';
export function main() {
helper();
}
// Nearly identical — TypeScript just strips types
- Use with:
moduleResolution: "bundler"(for bundled apps) - Best for: React apps (Next.js, Vite), browser apps, any bundled project
- Also includes:
es2015,es2020,es2022— these are essentially the same, just different ES edition targets.esnextmeans "latest supported" - Note: Don't run
module: "esnext"output directly in Node.js without a bundler — Node needs extensions
module: "nodenext" / "node16"
The correct setting for Node.js native ESM (no bundler). It emits proper Node.js ESM
or CJS depending on the file extension (.mts → .mjs, .cts → .cjs,
.ts → depends on package.json "type").
Setting module: "nodenext" automatically sets moduleResolution: "nodenext" — they're linked.
{
"compilerOptions": {
"module": "nodenext", // implies moduleResolution: "nodenext"
"target": "es2022",
"outDir": "./dist",
"declaration": true,
"strict": true
},
"include": ["src"]
}
{
"type": "module", // .js files are ESM
"main": "./dist/index.js",
"exports": {
".": "./dist/index.js"
}
}
node16 vs nodenext
node16 is locked to Node.js 16 behavior. nodenext is the forward-looking alias that will track future Node.js changes. Prefer nodenext for new projects.
module: "preserve" TS 5.4+
The newest option (TypeScript 5.4). It preserves whatever module syntax the source file uses —
if you wrote import, you get import; if you wrote require, you get require.
Like esnext but stricter: it signals to TypeScript "I have a bundler, don't touch module syntax at all."
- Use with:
moduleResolution: "bundler" - Required when:
allowImportingTsExtensions: true - The right choice for Next.js 15+, Vite 5+ projects
module value |
Output format | Needs bundler? | Use for |
|---|---|---|---|
"commonjs" | CJS require() | No | Node.js servers, Lambda |
"esnext" / "es2022" | ESM import | Yes | Browser apps, bundled projects |
"nodenext" | ESM or CJS per file | No | Node.js native ESM packages |
"preserve" | As-is (no transform) | Yes | Next.js 15+, Vite (modern) |
Setup Recipes
Recipe 1: Next.js / React App (Vite)
{
"compilerOptions": {
"module": "preserve", // or "esnext"
"moduleResolution": "bundler", // bundler handles file resolution
"target": "es2022",
"lib": ["es2022", "dom"],
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"noEmit": true, // vite/next does the emitting
"allowImportingTsExtensions": true
}
}
Recipe 2: Node.js Express Server (CJS)
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2022",
"lib": ["es2022"],
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"declaration": true
}
}
Recipe 3: Node.js CLI / Library (Native ESM)
{
"compilerOptions": {
"module": "nodenext", // sets moduleResolution: nodenext implicitly
"target": "es2022",
"outDir": "./dist",
"strict": true,
"declaration": true,
"declarationMap": true
}
}
{
"type": "module",
"exports": { ".": "./dist/index.js" },
"scripts": { "build": "tsc" }
}
And you must use .js extensions in your source imports:
import { helper } from './helper.js'; // .js even though source is .ts
import { readFile } from 'fs/promises'; // node built-ins: no extension
Recipe 4: Dual CJS+ESM Library (publish to npm)
{
"name": "my-lib",
"version": "1.0.0",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.ts",
"default": "./dist/cjs/index.js"
}
}
}
}
// Build with two tsconfig files:
// tsconfig.esm.json → module: "nodenext", outDir: dist/esm
// tsconfig.cjs.json → module: "commonjs", outDir: dist/cjs
Common Errors & Fixes
1. ERR_REQUIRE_ESM
Error [ERR_REQUIRE_ESM]:
require() of ES Module ./node_modules/chalk/source/index.js
not supported.
// You're doing:
const chalk = require('chalk'); // chalk v5 is ESM-only
// Option A: use dynamic import
const { default: chalk } = await import('chalk');
// Option B: pin to last CJS version
// npm install chalk@4
// Option C: convert your project to ESM
// package.json: "type": "module"
// tsconfig: module: "nodenext"
2. "Relative import paths need explicit file extensions"
// error TS2835: Relative import paths need explicit
// file extensions in ECMAScript imports when
// '--moduleResolution' is 'node16' or 'nodenext'.
import { foo } from './foo';
// Add .js extension (even for .ts source files)
import { foo } from './foo.js';
// OR: switch to "bundler" if you're using a bundler
// tsconfig: "moduleResolution": "bundler"
3. "Cannot use import statement in a module"
SyntaxError: Cannot use import statement
in a module
// Happens when: compiled .js still has
// import/export BUT package.json has
// "type": "commonjs" (or no type field)
// Option A: add "type": "module" to package.json
// Option B: change tsconfig module to "commonjs"
{
"module": "commonjs"
}
// Option C: rename output to .mjs
4. "Option 'moduleResolution: bundler' requires module: esnext or preserve"
// error TS5109: Option '--moduleResolution'
// can only be used when '--module' is set to
// 'preserve', 'es2015', 'es2020', 'es2022',
// 'esnext', 'node16', or 'nodenext'.
"module": "commonjs",
"moduleResolution": "bundler" // ❌
"module": "esnext", // ✅
"moduleResolution": "bundler"
// or
"module": "preserve", // ✅
"moduleResolution": "bundler"
5. Module not found / subpath export not resolving
// Cannot find module 'my-pkg/components'
// (package uses "exports" subpaths)
import { Button } from 'my-pkg/components';
// Switch to bundler or nodenext resolution
// which reads package.json "exports" field
{
"moduleResolution": "bundler" // ✅ reads "exports"
}
Decision Guide
Answer these questions to pick the right tsconfig settings.
moduleResolution: "bundler"
moduleResolution: "node"
(sets moduleResolution: "nodenext" automatically)
Quick compatibility matrix
| Scenario | module |
moduleResolution |
"type" in pkg.json |
|---|---|---|---|
| Next.js 14+ | "preserve" | "bundler" | n/a (Next handles it) |
| Vite React app | "esnext" | "bundler" | "module" |
| Express server | "commonjs" | "node" | "commonjs" or omit |
| Node.js CLI (ESM) | "nodenext" | "nodenext" | "module" |
| npm library (dual) | Two builds | Two tsconfigs | "module" |
| Bun app | "esnext" | "bundler" | "module" |
| AWS Lambda (CJS) | "commonjs" | "node" | "commonjs" |
| AWS Lambda (ESM) | "nodenext" | "nodenext" | "module" |