skip to content

Default Export Interop Mismatch

Why importing a CommonJS package sometimes hands you `{ default: fn }` instead of the function, and what the __esModule marker and interop helpers do about it. This is the most common interop bug interviewers ask you to debug from a stack trace.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

4

A CommonJS file `logger.cjs` ends with `module.exports = function log(msg) { console.log(msg); }`. In Node's native ESM, what does `import log from './logger.cjs'` bind, and why can the same import statement compiled to CommonJS by TypeScript or Babel end up binding `undefined` instead?

level: middleimportance: must knowfreq 68%

answer

  1. two rules, one statement
  2. what is the default of a CJS module
  3. the read is .default, the value is not
  4. a marker decides whether to wrap
  5. who injects the wrapper, and when

basics

~20 s

Node's ESM loader exposes a CommonJS module's entire module.exports value as its default export, so the import binds the function. A compiler instead rewrites the import to read a default property off the required object, which does not exist unless an interop helper wraps it.

solid answer

~40 s

Under Node's native ESM, the rule is simple: for a CommonJS module, the default export *is* `module.exports`. So `log` is the function itself and calling `log('hi')` works. Compiled code plays by a different rule. Lowering `import log from './logger.cjs'` to CommonJS, the compiler emits something like `const logger = require('./logger.cjs')` and then uses `logger.default` at every use site — and `module.exports` is a bare function with no `default` property, so you get `undefined is not a function`. That gap is exactly what interop helpers close: TypeScript's `esModuleInterop` behaviour emits an `__importDefault` wrapper, and Babel emits `_interopRequireDefault`. Both check for the `__esModule` marker and, when it is missing, wrap the value as `{ default: moduleExports }` so the `.default` read finds the function.

code

javascript · 14 lines
javascript
function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

const handWritten = function log(msg) { return msg; };
const compiled = Object.defineProperty(
  { default: function log(msg) { return msg; } },
  '__esModule',
  { value: true }
);

console.log(typeof _interopRequireDefault(handWritten).default); // 'function'
console.log(typeof _interopRequireDefault(compiled).default);    // 'function'
console.log(typeof handWritten.default);                          // 'undefined'

go deeper

for a junior

Recall that in Node's ESM a default import of a CommonJS file gives you the whole module.exports value, so a module assigning a function exports that function. Say plainly what the import binds.

for a middle

Explain both encodings side by side and write the interop helper from memory: check __esModule, pass through if present, otherwise wrap the exports value as an object with a default key.

for a senior

Diagnose it from the symptom. The trace points at a call site while the cause is the import lowering, so demonstrate logging the required value to tell a hand-written CommonJS module from compiled output before changing anything.

for a principal

Own the policy question: which parts of the codebase are allowed to cross module systems, whether the build emits interop wrappers uniformly, and how you keep a mix of compiled and native modules from producing loader-dependent behaviour in production.

## Two different answers to the same question "What is the default export of a CommonJS module?" has two answers in circulation, and the whole interop mismatch follows from that. - **Node's native ESM loader** answers: the default export is the `module.exports` value, whatever it is. - **A compiler lowering ESM to CommonJS** answers: the default export is the `default` property of the required object. When a module was hand-written as CommonJS with `module.exports = fn`, those two answers disagree. Answer one gives you `fn`. Answer two gives you `fn.default`, which is `undefined`. ## The native ESM path ```js // logger.cjs module.exports = function log(msg) { console.log(msg); }; ``` ```js // app.mjs import log from './logger.cjs'; log('hi'); // works ``` Node wraps the CommonJS module in a synthetic ES module whose default export is bound to `module.exports`. Named imports are a separate, best-effort mechanism: Node statically scans the CommonJS source for recognisable `exports.foo = ...` patterns and exposes what it finds. But the default binding is unconditional — it does not depend on any marker, and it does not depend on the shape of `module.exports`. A function, an object, a string, `null`: whatever the module assigned is what the default import receives. One consequence worth internalising: because the default binding is unconditional, Node's loader also does **not** unwrap a compiled package. If `module.exports` happens to be `{ __esModule: true, default: fn }`, then the default import binds that whole object, and you reach the function at `log.default`. Node ignores `__esModule`; bundlers generally honour it. Same statement, different value, depending on the loader. ## The compiled path Now compile `app.mjs` to CommonJS. Without an interop helper, the emit is essentially: ```js 'use strict'; const logger_cjs_1 = require('./logger.cjs'); (0, logger_cjs_1.default)('hi'); // TypeError: logger_cjs_1.default is not a function ``` The compiler is not being obtuse. It is applying its own encoding consistently: when *it* emits a default export it writes `exports.default`, so when it reads one it reads `.default`. The encoding is symmetric and self-consistent — it simply does not describe modules that were never compiled. ## The interop helper The fix is a runtime wrapper injected around every `require()` that feeds a default import. Babel's version: ```js function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } const logger_cjs_1 = _interopRequireDefault(require('./logger.cjs')); logger_cjs_1.default('hi'); // works ``` TypeScript's `esModuleInterop` behaviour emits the equivalent under the name `__importDefault`, with the same body. The logic reads cleanly: if the required object carries the `__esModule` marker it is already compiler output and its `.default` is meaningful, so pass it through untouched. Otherwise it is a genuine CommonJS module, so synthesise the wrapper `{ default: obj }` and the `.default` read lands on `module.exports`. That single line reconciles the two answers above. There is a companion helper for namespace imports — `_interopRequireWildcard` / `__importStar` — which copies the CommonJS properties onto a fresh object and additionally sets `default` to the whole `module.exports`, so `import * as ns` gives both `ns.someName` and `ns.default`. ## Why interviewers like this one It produces a stack trace that points at the wrong place. The failure surfaces at the *call site* as `TypeError: ... is not a function` or `Cannot read properties of undefined`, while the cause is the import encoding several lines above. Candidates who have not seen it tend to blame the package. The diagnostic move is to log the required value before using it: ```js console.log(require('./logger.cjs')); // [Function: log] -> hand-written CJS, need the interop wrapper // { __esModule: true, default: [Function] } -> compiled from ESM, .default is correct ``` That one line distinguishes the two worlds immediately. ## The important asymmetry Interop helpers cannot recover the reverse direction. A helper can synthesise a default export for a module that has none, because there is an obvious candidate — the whole exports value. It cannot synthesise *named* exports that were never statically visible, because there is nothing to guess from. That is why default-import interop is a solved, mechanical problem while named-import interop from CommonJS remains best-effort and can fail outright.

  • What does the interop helper do differently when the required module *does* carry `__esModule: true`?
    It passes the object through unchanged. The marker means the object is already lowered ESM output, so its `default` property is a real default export and the `.default` read at the use site will find it. Wrapping again would produce `{ default: { __esModule: true, default: fn } }` and push the function one level deeper — the double-default bug you occasionally see when two layers of interop stack up.
  • How does the equivalent helper for `import * as ns` differ from the default-import one?
    The namespace helper builds a new object, copies the CommonJS module's own enumerable properties onto it so `ns.someName` works, and also sets `default` to the whole `module.exports` value. It has to approximate a module namespace object, whereas the default helper only needs to produce something with a usable `default` key.
  • Why does `import * as express from 'express'; express();` fail under spec-correct interop even though it once worked in compiled output?
    A module namespace object is not callable — the specification says so. Older compiler output aliased the namespace directly to `require()`'s return value, so calling it happened to hit the CommonJS function. Under spec-faithful interop the namespace is a real object and the call throws, so the callable value must come from a default import instead.

saying these in an interview costs you the question

  • Says a CommonJS module has no default export at all
  • Assumes Node reads __esModule to pick the default
  • Blames the package instead of the import encoding
  • Thinks the helper unwraps .default rather than wrapping
  • Claims named imports from CommonJS always work like default

context

open as a page

In a CommonJS file, `const pkg = require('some-package')` returns an object shaped like `{ __esModule: true, default: [Function] }`, and calling `pkg()` throws `TypeError: pkg is not a function`. What produced that shape, and what does it tell you about how the package was built?

level: juniorimportance: should knowfreq 55%

basics

~20 s

The package was written as ES modules and compiled down to CommonJS. Compilers put an ESM default export on the exports object under a default property and add an __esModule marker, so the callable value is pkg.default, not pkg.

open as a page

Transpiled CommonJS output often begins with `Object.defineProperty(exports, "__esModule", { value: true })`. What is that marker for, and how do interop helpers such as Babel's `_interopRequireDefault` or the `__importDefault` helper emitted by TypeScript use it?

level: middleimportance: should knowfreq 42%

basics

~20 s

It is a tooling convention marking a CommonJS exports object as lowered ES module output, so its default property is a real default export. Interop helpers read it to decide whether to pass the object through or wrap it as { default: value }.

open as a page

In Node's native ESM, `import { parse } from 'legacy-cjs-pkg'` fails with `SyntaxError: The requested module 'legacy-cjs-pkg' does not provide an export named 'parse'`, yet `require('legacy-cjs-pkg').parse` is a function at runtime. Explain what causes that mismatch and how you would fix the import site.

level: seniorimportance: should knowfreq 40%

basics

~20 s

Named exports from a CommonJS module are discovered by statically scanning its source before it runs, so properties attached dynamically are invisible to the linker even though they exist at runtime. Default-import the module and destructure the property afterwards.

open as a page