Resolving Named Export Not Found Errors in ESM/CJS Interop

The requested module 'some-cjs-pkg' does not provide an export named 'foo' appears when an ESM file imports a named binding from a CommonJS package whose exports the bundler could not detect statically. This guide explains the root cause in cjs-module-lexer and gives a runnable repro plus four concrete fixes. For the broader condition-matching model behind interop, read Understanding ESM vs CommonJS in Modern Bundlers before applying any of them.

Why a named import fails against a CommonJS module cjs-module-lexer scanning a CJS module, detecting only the default export when exports are assigned dynamically, so a named import binding is missing. CJS module (dynamic) const o = {}; o["foo"] = fn; module.exports = o; no static export names cjs-module-lexer static scan, no execution detects: default only default export resolves OK import { foo } not provided → error Fix: import the default, then destructure import pkg from 'some-cjs-pkg'; const { foo } = pkg; // members exist at runtime Or: pre-bundle optimizeDeps.include forces esbuild to map members.
Figure: cjs-module-lexer scans without executing, so dynamically assigned members are invisible — the default import resolves but a named import does not.

Problem scope and reproducible setup

The error surfaces only when ESM code uses import { name } against a CommonJS dependency. The default import almost always works; the named binding is what fails, because the bundler builds the named-export list by statically scanning the CJS source, never by executing it. Reproduce it:

# Vite 5.x / 6.x, Node 20+
npm create vite@latest namedexp-repro -- --template vanilla-ts
cd namedexp-repro && npm i

Create a CJS package that assigns its exports dynamically — exactly the shape cjs-module-lexer cannot read:

// node_modules/quirky-cjs/index.js  (simulate a real dependency)
// CommonJS — exports assigned at runtime, not via static literals.
const api = {};
api.parse = (s) => JSON.parse(s);
['format', 'validate'].forEach((name) => {
  api[name] = (x) => x; // members added in a loop — invisible to a static scan
});
module.exports = api;
// node_modules/quirky-cjs/package.json
{ "name": "quirky-cjs", "version": "1.0.0", "main": "index.js" }
// src/main.ts — the import that triggers the error
import { parse, format } from 'quirky-cjs';
console.log(parse('{"ok":true}'), format('x'));

npm run dev then throws in the browser console:

Uncaught SyntaxError: The requested module '/node_modules/.vite/deps/quirky-cjs.js'
does not provide an export named 'format'

Diagnosis workflow

  1. Confirm the dependency is CJS. Check its package.json: no "type": "module", a "main" entry, and module.exports in the source. A pure-ESM package would not hit this path.
  2. Confirm the default import works. Temporarily switch to import pkg from 'quirky-cjs' and log pkg.format. If the member exists at runtime, the failure is purely a detection gap, not a missing export.
  3. Read the lexer output. Vite uses es-module-lexer and cjs-module-lexer during pre-bundle. The members detected are exactly those assignable to module.exports.<name> or exports.<name> via static literals; loop-, computed-, or Object.assign-based exports are not detected.
  4. Inspect the pre-bundle. Open node_modules/.vite/deps/quirky-cjs.js and look at the trailing export { default } line. If your named member is absent there, the lexer missed it.

The complete solution

The robust, dependency-shape-agnostic fix is to stop asking the lexer to enumerate members: import the default binding (always present for CJS interop) and destructure at runtime, where the members genuinely exist.

// src/main.ts — Vite 5.x / 6.x, Node 20+
// Default import always resolves for a CJS module; destructure the live object.
import pkg from 'quirky-cjs';
const { parse, format, validate } = pkg;

console.log(parse('{"ok":true}'), format('x'), validate('y'));

When you would rather keep the named-import syntax across the codebase, force esbuild to pre-bundle the dependency. esbuild executes the module during pre-bundling, so it captures dynamically assigned members and re-exports them as real named bindings:

// vite.config.ts — Vite 5.x / 6.x, Node 20+
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // esbuild runs the module and synthesizes named exports the lexer missed
    include: ['quirky-cjs'],
  },
  ssr: {
    // For vite build --ssr: bundle the CJS dep so Node does not re-introduce the
    // lexer path at runtime; pairs with the default-import fix above.
    noExternal: ['quirky-cjs'],
  },
});

If a single internal interop boundary needs a stable shim — for example a shared package re-exported across an app — wrap it once:

// src/shims/quirky-cjs.ts — one explicit interop shim, imported everywhere
import pkg from 'quirky-cjs';
export const parse = pkg.parse;
export const format = pkg.format;
export const validate = pkg.validate;

Verification

# Rebuild the pre-bundle from clean so optimizeDeps changes take effect
rm -rf node_modules/.vite && npx vite dev --force

# Confirm esbuild now emits the named member into the pre-bundle
grep -E "format|validate" node_modules/.vite/deps/quirky-cjs.js

# Production build must complete with no 'does not provide an export named' error
npx vite build && npx vite preview

After the fix, the dev console logs the parsed object and the build exits 0. The pre-bundle file should now contain an explicit export { … format, validate … } line rather than only export { default }.

Gotchas & edge cases

  • optimizeDeps is dev/SSR only by default for transitive deps. A package imported only by another dependency may not be discovered; add it explicitly to include rather than relying on auto-discovery.
  • Re-exported barrels hide the CJS source. If quirky-cjs is re-exported through an ESM index.js barrel, the lexer scans the barrel and still misses the underlying CJS members — pin the original package in optimizeDeps.include.
  • TypeScript types lie about runtime shape. A .d.ts can declare named exports the CJS runtime assigns dynamically; the types compile but the import fails at load. Trust the runtime object, not the declaration.
  • interop: 'auto' fixes the default, not the named list. Setting Rollup’s output.interop resolves 'default' is not exported but does not retroactively detect dynamic members — you still need the default-destructure or pre-bundle path. See how to configure ESM and CJS interop in Vite for the full set of Vite overrides.