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.

F

Frontend Interview Team

March 01, 2026

4 min read
ESM vs CJS in TypeScript (Interop Without Tears)

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.