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.
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
esModuleInteropandallowSyntheticDefaultImports - 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
- Source syntax: what you write (
import,export,require,module.exports) - Emitter: what TypeScript outputs (or what your bundler outputs)
- 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
allowSyntheticDefaultImportsand assuming runtime will follow - Mixing
moduleResolution: bundlerlocally 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)
-
Q: Does TypeScript decide whether code is ESM or CJS?
- A: TypeScript influences output, but runtime (Node/bundler) decides how modules load.
-
Q: What does
esModuleInteropdo?- A: It changes emit + helper behavior to make CJS/ESM interop smoother, especially default imports when targeting CJS.
-
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.
allowSyntheticDefaultImportsis types-only.- Prefer named imports for ESM, namespace imports for CJS, and avoid “guessy” default imports.
Looking for structured, offline interview prep?
Free online lessons • Portable Amazon Kindle / Paperback
Frontend Interview Guide (2025 Edition)
By Rajesh Dhiman
Comprehensive preparation covering React internals, Web Performance, CSS architecture, and system design questions.
JavaScript Interview Guide
By Rajesh Dhiman
Deep dive into the Event Loop, microtask draining, closures, prototypal inheritance, and asynchronous execution patterns.
Note: All lessons on Frontend Interview remain completely free to read online. As an Amazon Associate, we may earn from qualifying purchases.
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.