Fixing Default Export Interop Errors Between ESM and CJS

The import looks unremarkable and the error is immediate:

import chalk from 'chalk';
chalk.green('ok');
TypeError: chalk.green is not a function
    at Module../src/report.js (index-4f2a91.js:1:14822)

Logging the binding explains it:

console.log(chalk);
// { default: { green: [Function], red: [Function], … }, __esModule: true }

The callable object is one level down, on default. Reaching for chalk.default.green makes the error go away and is the wrong fix — it encodes the current interop behaviour into your source, so the code breaks again when the package, the bundler, or the resolution changes.

Root cause: two module systems with incompatible ideas of “default”

CommonJS has no notion of named exports. A module assigns to one object, and require returns that object. ES modules have named exports plus a distinguished default binding, resolved statically. The two models do not map onto each other cleanly, and the gap has to be bridged by a convention.

The convention bundlers use: when an ESM module default-imports a CommonJS module, the entire module.exports object becomes the default. That is the right behaviour, and it produces surprising results in two cases.

If the CommonJS module was itself transpiled from ESM, its exports object already contains a default key and an __esModule: true marker. Applying the wrapping again nests the real value: default.default.

If the package publishes both formats, which entry you resolve determines whether interop is applied at all — the same resolution question behind resolving dual package hazard in ESM and CJS builds.

Where the Callable Ends Up Importing a real ESM build puts the function on the binding; importing a CommonJS build wraps it under default; importing a CommonJS build that was transpiled from ESM wraps it twice. real ESM build export default fn chalk.green() works — no interop involved CommonJS build module.exports = fn { default: fn } wrapped once — usually fine CJS transpiled from ESM exports.default = fn { default: { default: fn } } wrapped twice — the error The same import statement, three different shapes at runtime which one you get depends on which entry the resolver picked

Fix 1: resolve the ESM entry when one exists

The cleanest fix removes the interop layer entirely. Most maintained packages publish an ESM build; the resolver just needs to be told to prefer it.

// vite.config.js — Vite 5+
export default {
  resolve: {
    // 'module' before 'main' selects the ESM build where the package has one.
    mainFields: ['module', 'browser', 'jsnext:main', 'main'],
    conditions: ['import', 'module', 'browser', 'default'],
  },
};
// webpack.config.js — Webpack 5
module.exports = {
  resolve: {
    mainFields: ['module', 'browser', 'main'],
    conditionNames: ['import', 'module', 'browser', 'default'],
  },
};

This also improves tree-shaking, since an ESM entry gives the bundler the static export graph that understanding ES modules vs CommonJS in bundlers describes — the same reason the migration is worth doing generally.

Fix 2: normalise in one place when it does not

For a package that only ships CommonJS, put the interop in a single wrapper rather than at every call site.

One Wrapper Absorbs the Shape Consumers import a normalised binding from a single wrapper, so a later change in resolution touches one file instead of every call site. report.js invoice.js export.js lib/chalk.js unwraps default nesting once the package whatever shape it has No call site ever names .default — so no call site breaks when resolution changes
// lib/chalk.js — one place that knows about interop
import chalkModule from 'chalk';

// Handles all three shapes: bare function, single wrap, double wrap.
function unwrap(mod) {
  let value = mod;
  while (value && typeof value === 'object' && 'default' in value) value = value.default;
  return value;
}

export const chalk = unwrap(chalkModule);

Every consumer imports { chalk } from the wrapper and never sees the interop. If the package later ships ESM, or the resolution changes, one file changes.

Fix 3: force a named import for pre-bundled dependencies

Vite pre-bundles CommonJS dependencies into ESM during development, and occasionally its export detection misses a name. The fix is to declare the exports rather than force a default import:

// vite.config.js — Vite 5+: tell the pre-bundler what a package exports
export default {
  optimizeDeps: {
    // Named exports the CommonJS scanner cannot infer statically.
    include: ['legacy-charting'],
    esbuildOptions: {
      // Synthesise a default export from module.exports for packages
      // that are known to be plain CommonJS.
      mainFields: ['module', 'main'],
    },
  },
};

Fix 4: check the interop flag on the Webpack side

Webpack decides how aggressively to apply interop per module type, and the setting can be adjusted when a specific dependency misbehaves:

// webpack.config.js — Webpack 5
module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        // 'node' matches Node's own interop semantics, which is what most
        // packages are actually written and tested against.
        parser: { commonjsMagicComments: true },
        resolve: { fullySpecified: false },
      },
    ],
  },
};

Change this last, and only for a specific rule scope. A global interop change fixes one package and quietly alters the shape of every other CommonJS import in the graph.

Choosing the Interop Fix If the package publishes an ESM build, prefer it in resolution. If not, normalise in a wrapper module. Adjust bundler interop settings only as a last resort. interop error callable is under default Package publishes an ESM build? Prefer it in resolution no interop layer at all yes Normalise in a wrapper one file knows the shape no Last resort: bundler interop settings global changes affect every CommonJS import

Step-by-step verification

  1. Log the shape before changing anything. Print the imported binding and confirm exactly how deeply the callable is nested. The fix depends on the answer.

  2. Check which entry resolved. Print the resolved path for the package to confirm whether you received an ESM or CommonJS build.

  3. Verify in a production build. Development servers and production builds apply interop differently; a fix that works in one can fail in the other.

  4. Check named imports too. A package can expose its default correctly and still fail on named imports, because a CommonJS module’s names are inferred rather than declared.

  5. Confirm tree-shaking improved. Switching to an ESM entry should reduce the package’s shipped bytes — if it did not, the CommonJS build is still winning somewhere.

  6. Search for defensive access. Grep for .default.default and any similar patterns and remove them once the underlying resolution is fixed.

Edge cases and gotchas

Dynamic import of a CommonJS module. An awaited import() of a CommonJS module resolves to a namespace object whose default is module.exports, which is a third shape distinct from both static-import cases.

Server and client resolving differently. Under SSR, the server build may resolve the CommonJS entry while the client resolves ESM, so the same import has two shapes in one application.

TypeScript types disagreeing with runtime. With esModuleInterop enabled, the types describe the normalised shape whether or not the runtime provides it, so the compiler will not catch this class of error.

Transitive interop. A dependency that itself imports a CommonJS package incorrectly produces the error inside that package’s code, which is not yours to fix — pin a version that works and report it upstream.

FAQ

Why does my import work in development and fail in the production build?

Because the two paths apply interop differently. A dev server that pre-bundles dependencies normalises many CommonJS packages into ESM-shaped modules with a synthesised default export, so the import looks correct. The production build may resolve a different entry or apply a stricter interop rule, and the same import then yields the module namespace object instead of the callable. Always verify interop against a built bundle.

What exactly is default.default?

It is what you get when interop is applied twice. A CommonJS module’s exports object is wrapped once so that the whole object becomes the default export; if a transpiled layer then wraps it again, the original value ends up nested one level deeper. Reaching for it directly makes the symptom disappear and leaves the double wrapping in place, so it breaks again as soon as either side’s build changes.

Does esModuleInterop in TypeScript fix this?

It fixes the type-checking half and only sometimes the runtime half. The flag makes TypeScript accept a default import from a CommonJS module and emit a helper that normalises it — which works when TypeScript is also doing the emit. When a bundler handles the transform instead, the emitted helper may not be involved, so the types say the import is a function while the runtime disagrees. Trust the runtime check, not the type.