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.
Frontend Interview Team
March 01, 2026
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.