8.1 ESM vs CJS — Understanding Module Systems
What are Module Systems?
JavaScript has two primary module systems:
- CJS (CommonJS): The default system used in Node.js (
require/module.exports) - ESM (ES Modules): The standard system supported by both browsers and Node.js (
import/export)
TypeScript supports both, but incorrect configuration can cause runtime errors.
CJS (CommonJS)
Basic Syntax
// math.js (CJS)
function add(a, b) {
return a + b;
}
module.exports = { add };
// main.js (CJS)
const { add } = require('./math');
console.log(add(1, 2)); // 3
TypeScript with CJS
// tsconfig.json: "module": "CommonJS"
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
// main.ts
import { add } from './math';
// Compiled output: const { add } = require('./math');
ESM (ES Modules)
Basic Syntax
// math.ts (ESM)
export function add(a: number, b: number): number {
return a + b;
}
export default function multiply(a: number, b: number): number {
return a * b;
}
// main.ts (ESM)
import multiply, { add } from './math.js'; // .js extension required in ESM!
console.log(add(1, 2)); // 3
console.log(multiply(2, 3)); // 6
package.json Configuration
{
"type": "module"
}
With "type": "module", .js files are treated as ESM.
tsconfig.json module Options
{
"compilerOptions": {
"module": "CommonJS", // Node.js CJS
"module": "ESNext", // Latest ESM
"module": "NodeNext", // Node.js ESM (same as Node16)
"module": "Preserve", // Keep input format as-is (TS 5.4+)
"moduleResolution": "node", // Traditional Node resolution
"moduleResolution": "bundler", // For Vite/webpack bundlers (TS 4.9+)
"moduleResolution": "NodeNext" // Node.js ESM resolution
}
}
module vs moduleResolution
| Option | Role |
|---|---|
module | The module format TypeScript outputs |
moduleResolution | How import paths are resolved |
File Extension Rules in ESM
In ESM mode (NodeNext), TypeScript files use .ts but imports must use .js.
// ✅ Correct (NodeNext mode)
import { add } from './math.js'; // .ts file, but import with .js
// ❌ Wrong
import { add } from './math'; // No extension — error
import { add } from './math.ts'; // .ts extension — error
Why use .js?
TypeScript compiles .ts → .js, so .js files exist at runtime. NodeNext mode follows this runtime resolution.
CJS ↔ ESM Interop
Importing CJS Modules from ESM
// Using CJS libraries from ESM
import express from 'express'; // default import
import { Router } from 'express'; // named import
import * as fs from 'fs'; // namespace import
esModuleInterop Option
{
"compilerOptions": {
"esModuleInterop": true // Recommended: improves CJS ↔ ESM compatibility
}
}
Without esModuleInterop: true, import express from 'express' will error.
// esModuleInterop: false (legacy)
import * as express from 'express';
const app = express();
// esModuleInterop: true (recommended)
import express from 'express';
const app = express();
allowSyntheticDefaultImports
Automatically enabled when esModuleInterop: true is set. Allows import X from '...' for CJS modules without a default export.
moduleResolution: "bundler" Deep Dive
Added in TypeScript 4.9, bundler mode is optimized for Vite, webpack, esbuild, and similar bundlers.
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true // Allow .ts/.tsx extensions
}
}
// bundler mode: no extension required
import { add } from './math'; // ✅ bundler handles resolution
import { Button } from './Button'; // ✅ .tsx auto-resolved
Mode Comparison
| Mode | Extension Required | Environment |
|---|---|---|
node | Not required | Legacy Node.js CJS |
NodeNext | Required (.js) | Node.js ESM |
bundler | Not required | Vite, webpack, Next.js |
Real-World Configuration Examples
Node.js CJS Project (Express API)
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
Node.js ESM Project
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
// package.json
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Vite / Next.js Frontend Project
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true
}
}
Common Errors and Solutions
Error 1: Cannot use import statement in a module
SyntaxError: Cannot use import statement in a module
Cause: Using ESM syntax without "type": "module" in package.json
Fix: Add "type": "module" or change to module: CommonJS
Error 2: ERR_REQUIRE_ESM
Error [ERR_REQUIRE_ESM]: require() of ES Module ...
Cause: CJS code trying to require() an ESM-only package
Fix:
// Use dynamic import
const { default: chalk } = await import('chalk');
Error 3: Extension-related error (NodeNext mode)
Relative import paths need explicit file extensions in ECMAScript imports.
Fix: Add .js extension to import paths
import { helper } from './helper.js'; // ✅
Pro Tips
1. Recommended settings by project type
Backend (Node.js): module: NodeNext + moduleResolution: NodeNext
Frontend: module: ESNext + moduleResolution: bundler
Library: module: NodeNext (consider dual CJS/ESM packaging)
2. Dual package (CJS + ESM simultaneous support)
// package.json
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
3. verbatimModuleSyntax (TS 5.0+)
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
Forces type-only imports to be explicitly marked.
import type { User } from './types'; // ✅ type-only
import { type User, getUser } from './api'; // ✅ mixed