Skip to main content

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​

OptionRole
moduleThe module format TypeScript outputs
moduleResolutionHow 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​

ModeExtension RequiredEnvironment
nodeNot requiredLegacy Node.js CJS
NodeNextRequired (.js)Node.js ESM
bundlerNot requiredVite, 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');

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