Skip to content

ESM vs CJS in TypeScript (Interop Without Tears)

Understand what TypeScript emits vs what Node runs, and how to avoid the common import/export traps in real codebases.

Rajesh DhimanMarch 01, 20264 min read
On this page

What you’ll learn

  • The difference between TypeScript’s type system and runtime module loading
  • Why import x from "cjs" sometimes works and sometimes explodes
  • The practical meaning of esModuleInterop and allowSyntheticDefaultImports
  • A safe “team policy” for imports that prevents most module bugs

30‑second interview answer

ESM vs CJS is a runtime concern. TypeScript can type-check either style, but whether the code runs depends on Node/bundler rules and your output settings. Most “default import” failures come from mixing ESM syntax with CJS exports (or vice versa) without consistent compiler + runtime configuration. In 2026, the safest approach is: pick one runtime (ESM or CJS) for the package, then configure TS (module, moduleResolution, verbatimModuleSyntax) to match it.


Mental model: 3 layers that must agree

  1. Source syntax: what you write (import, export, require, module.exports)
  2. Emitter: what TypeScript outputs (or what your bundler outputs)
  3. Loader: what executes the output (Node ESM loader, Node CJS loader, Vite/Webpack)

If any layer disagrees, you get bugs like:

  • “Cannot use import statement outside a module”
  • “require is not defined”
  • “The requested module does not provide an export named 'default'”

CJS vs ESM: what “default export” means

CommonJS (CJS)

At runtime, CJS exports a single object:

// cjs-lib/index.js
module.exports = {
  add(a, b) { return a + b; }
};

ESM

ESM has named exports and default exports:

// esm-lib/index.js
export const add = (a, b) => a + b;
export default function sum(a, b) { return a + b; }

Key point: CJS doesn’t truly have ESM “default export”. Interop is convention + tooling.


The two compiler flags everyone confuses

allowSyntheticDefaultImports

  • Affects type-checking only.
  • Lets you write import x from "cjs-lib" even if the typings don’t declare a default.

It does not guarantee runtime correctness.

esModuleInterop

  • Changes emit behavior for CJS interop.
  • Often makes default imports from CJS behave more predictably when you compile to CJS.

In many NodeNext setups (true ESM output), it’s not a magic fix because the runtime loader still matters.


Practical patterns (use these in production)

Pattern 1: When importing a CJS package, prefer namespace import

If the package is CJS-ish (many older libs are):

import * as pkg from "some-cjs-lib";
 
pkg.doThing();

This aligns with what CJS actually provides: an object.

Pattern 2: If the lib is definitely ESM with named exports, use named imports

import { z } from "zod";

Pattern 3: Avoid guessing “default import” unless the lib documents it

This is the trap:

import express from "express";

Depending on TS settings + runtime, express could be:

  • the real function
  • { default: fn }
  • an object with properties

If your environment is inconsistent across repos, you ship bugs.


Example: diagnosing a classic failure

Symptom

SyntaxError: The requested module 'x' does not provide an export named 'default'

Likely cause

You are running ESM output, but importing a CJS dependency using default import.

Fix options

  • Change import style:
import * as x from "x";
  • Or use a documented named export
  • Or use a build step/bundler that normalizes interop

Production rule of thumb (team policy)

If your app is ESM (NodeNext / type: module):

  • Use named imports for ESM libs
  • Use namespace imports for CJS libs
  • Treat default imports from third-party packages as “only if documented”

This policy prevents most cross-env bugs.


Common mistakes

  • Turning on allowSyntheticDefaultImports and assuming runtime will follow
  • Mixing moduleResolution: bundler locally but running plain Node in prod
  • Publishing a package with ESM code but CJS typings (or vice versa)

Interview questions (with the answers you want)

  1. Q: Does TypeScript decide whether code is ESM or CJS?

    • A: TypeScript influences output, but runtime (Node/bundler) decides how modules load.
  2. Q: What does esModuleInterop do?

    • A: It changes emit + helper behavior to make CJS/ESM interop smoother, especially default imports when targeting CJS.
  3. Q: Why can code compile but fail at runtime?

    • A: Types can be satisfied while the runtime loader can’t find the export shape you assumed.

Quick recap

  • ESM/CJS bugs happen when source, emit, and loader disagree.
  • allowSyntheticDefaultImports is types-only.
  • Prefer named imports for ESM, namespace imports for CJS, and avoid “guessy” default imports.
Companion Book

Looking for structured, offline interview prep?

Free online lessons • Portable Amazon Kindle / Paperback

Frontend Interview Guide (2025 Edition) cover
Recommended for React & Frontend Rounds

Frontend Interview Guide (2025 Edition)

By Rajesh Dhiman

Comprehensive preparation covering React internals, Web Performance, CSS architecture, and system design questions.

View on Amazon ↗Kindle & Paperback
JavaScript Interview Guide cover
Recommended for Core JS Rounds

JavaScript Interview Guide

By Rajesh Dhiman

Deep dive into the Event Loop, microtask draining, closures, prototypal inheritance, and asynchronous execution patterns.

View on Amazon ↗Kindle & Paperback

Note: All lessons on Frontend Interview remain completely free to read online. As an Amazon Associate, we may earn from qualifying purchases.

RD

Written by Rajesh Dhiman

Authored by Rajesh Dhiman and engineered at Eunix Tech. Focused on deep-dive frontend engineering, JavaScript runtime internals, React architecture, and web performance.