Skip to main content
Advertisement

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
Advertisement