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.

The error exists because ES modules and CommonJS disagree about when a module’s export names are known. In ESM, the set of exported bindings is fixed at parse time: the specification requires that export statements be static so that an importing module can be linked before any code runs. CommonJS has no such constraint — a module builds module.exports while it executes, and can attach members from a loop, a spread, a conditional, or a helper called three files away. When you write import { foo } from 'some-cjs-pkg', the bundler has to reconcile these two worlds: it must hand the ESM linker a concrete list of names before the CJS module has run. It does that by scanning the CJS source with a lexer, and the lexer only sees what is written as a literal assignment. Anything computed is invisible, and the missing binding surfaces as a load-time SyntaxError.

This matters because the failure is not a bug in your code or in the dependency — the member genuinely exists at runtime. It is an artifact of where the interop boundary sits in the build pipeline. In Vite the boundary is the dependency pre-bundle step (esbuild wrapped by es-module-lexer and cjs-module-lexer); in a Rollup production build it is @rollup/plugin-commonjs doing the same static analysis. Getting the fix wrong tends to produce one of two bad outcomes: a workaround that papers over the symptom in dev but breaks under vite build --ssr where a second interop path runs in Node, or a blanket optimizeDeps change that forces every dependency through esbuild and inflates cold-start time. The rest of this guide picks the fix that matches the dependency’s actual shape.

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. This asymmetry is the single most useful diagnostic signal: if import pkg from 'x' succeeds but import { foo } from 'x' throws, you are looking at a detection gap and not a genuinely absent export. The repro below reconstructs that exact shape with a synthetic dependency, so you can watch the failure happen and then confirm each fix against a known-good baseline rather than against a moving third-party target. Reproduce it:

Default import works, named import fails Against a CommonJS dependency the default import resolves because interop always provides it, but a named import fails because the bundler builds the named-export list by static scan and misses dynamically assigned members. import pkg from 'cjs'default always resolves import { foo } from 'cjs'named binding fails Same package — the syntax you use decides whether it resolves
Figure: the failure is about the named binding — the default import against the same package succeeds.
# 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'

Note that the error points at the pre-bundled copy under .vite/deps, not at the original package in node_modules/quirky-cjs. That path is the tell: Vite has already rewritten the CJS module into an ESM facade, and it is that facade — not the source — whose export list is missing your member. The browser is doing exactly what the spec demands, refusing to link a named import the module never declared.

How cjs-module-lexer decides which names exist

cjs-module-lexer is a WebAssembly-compiled parser that walks the CJS source once and records every export it can prove without running the code. It recognises a fixed catalogue of assignment shapes: module.exports.foo = …, exports.foo = …, Object.defineProperty(exports, 'foo', …) with a literal key, and a small set of re-export forms such as module.exports = require('./other') where it will recurse one level to pull names through. It deliberately does not evaluate expressions, resolve variables, or follow function calls, because doing so would mean executing untrusted dependency code during a static build step — the whole point of a lexer is that it is cheap and side-effect-free.

The consequences fall directly out of that design. A member added as exports['fo' + 'o'] is invisible because the key is computed. A member added inside ['format','validate'].forEach(...) is invisible because it lives behind a function call the lexer will not enter. Object.assign(exports, require('./impl')) is invisible because the lexer cannot know statically what keys ./impl contributes. In every one of these cases the member is perfectly real once the module executes — which is why the default import, which hands you the finished module.exports object, always works. The lexer’s output is only ever a superset guess of the named-export surface, and when the guess is short, the ESM linker treats the absent name as a hard error rather than a silent undefined.

This is also why the same package can behave differently across tools and versions. cjs-module-lexer ships new heuristics over time, so a member that one release misses a later release may detect; a package that “just works” on a colleague’s machine may fail on yours if the lockfile pins a different lexer. Treat detection as best-effort, not as a contract.

Diagnosis workflow

Four-step named-export diagnosis Confirm the dependency is CJS, confirm the default import works so the member exists at runtime, understand the lexer only detects static assignments, then inspect the pre-bundle's export line for the missing member. 1 is it CJS?main + exports 2 default works?member at runtime 3 lexer limitstatic only 4 pre-bundleexport line?
Figure: step 2 proves it is a detection gap, not a truly missing export — the fix follows from that.
  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 — its exports are already static and the linker reads them directly. The ambiguous case is a dual-package that ships both formats behind exports conditions; there, resolve which condition your build actually selects (import vs require vs browser) because only the CJS branch triggers the lexer. If the resolved entry ends in .mjs or the nearest package.json says "type": "module", you are not in this failure mode and should look elsewhere.
  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, and every fix below is safe to apply. If pkg.format is itself undefined, stop — the dependency really does not export that member under the conditions you resolved, and no interop trick will conjure it; you have a version mismatch or a wrong import path, not an interop bug.
  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. You can reproduce the exact verdict outside Vite by feeding the source to the lexer directly, which turns “I think detection failed” into a definitive answer before you change any config:
// probe-lexer.mjs — Node 20+, run: node probe-lexer.mjs
// Prints exactly the named exports cjs-module-lexer can see statically.
import { init, parse } from 'cjs-module-lexer';
import { readFileSync } from 'node:fs';

await init();
const src = readFileSync('node_modules/quirky-cjs/index.js', 'utf8');
const { exports, reexports } = parse(src);
console.log('detected exports:', exports);   // e.g. []  ← the smoking gun
console.log('re-exports to follow:', reexports);
  1. 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. This is the ground truth the browser sees, so it settles any disagreement between the source and the runtime object: a member present in the live object but absent from this generated export list is the precise definition of the bug.

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. This works precisely because it moves the member lookup from link time to run time. The interop layer guarantees a default export for any CJS module — it is the whole module.exports object — so destructuring it is reading properties off a plain object after the module has executed. No static analysis is involved, so no analysis can come up short. The trade-off is ergonomic, not behavioural: you lose the flat import { foo } form and named-import tree-shaking of the dependency, but a CJS dependency is not tree-shakeable through this boundary anyway, so you give up nothing real.

Three fixes by robustness The most robust fix is default-import then destructure at runtime; optimizeDeps.include forces esbuild to execute the module and synthesize the named exports; and a one-file interop shim gives a stable named surface across the app. default + destructureshape-agnosticalways works optimizeDeps.includeesbuild executes modulekeeps named syntax one interop shimstable named surfaceimported everywhere Destructure is the safest; pre-bundle keeps the ergonomic syntax
Figure: destructuring the default always works; optimizeDeps keeps named-import syntax by executing the module.
// 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. The mechanism is worth understanding: optimizeDeps.include promotes the package to esbuild’s entry list, esbuild bundles it into a single ESM file, and — because esbuild runs the CJS wrapper and observes the finished exports object — it can emit export { parse, format, validate } even for members the lexer alone would miss. In other words this fix does not make the lexer smarter; it replaces the lexer’s guess with the result of actually running the code once, at build time, in Node rather than the browser.

The cost is real but bounded. Pre-bundling adds the dependency to the cold-start work Vite does on first dev and re-triggers whenever the dep set changes, so listing large or rarely-used packages inflates startup latency for every developer. Prefer include on the specific offending package, not a broad glob. The result is cached under .vite/deps keyed by a hash of the config and lockfile, so steady-state runs pay nothing.

// 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;

The shim is the right call when many files consume the dependency and you want the named-import ergonomics without a config-level include entry. Because the shim is your own ESM source, its export const bindings are static and every tool downstream — Vite dev, Rollup build, your editor’s language server, and TypeScript — sees a clean named surface. It also gives you exactly one place to add types, rename a member, or intercept a call, which is valuable when the underlying package’s API is unstable. The downside is that it is manual: a member added to the dependency later will not appear until you extend the shim, so keep it narrow and close to the code that needs it rather than turning it into a growing re-export file.

For a dependency whose entire object you consume, a namespace re-export keeps the shim to a single line and stays current automatically:

// src/shims/quirky-cjs.ts — Vite 5.x / 6.x, Node 20+
// Re-export the whole default object as a namespace; no per-member list to maintain.
export { default as quirky } from 'quirky-cjs';
// usage elsewhere: import { quirky } from '@/shims/quirky-cjs'; quirky.format('x');

Verification

Verify the named member reaches the pre-bundle Rebuild the pre-bundle from clean with --force, grep the pre-bundle for the named member, then run vite build to confirm no does-not-provide-an-export error; the pre-bundle should now show an explicit export line, not only export default. rm .vite + dev --force rebuilds pre-bundle grep format|validate deps/ export { …format } vite build + preview exit 0
Figure: the pre-bundle gaining an explicit named export line (not just export default) is the proof.
# 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 }. The --force flag matters here: without it Vite reuses the cached .vite/deps metadata and your optimizeDeps.include change may not take effect, so a “the fix didn’t work” report is very often a stale cache rather than a wrong config. Verifying both the dev path and vite build is deliberate — the dev pre-bundle and the Rollup production bundle run separate interop code paths, and a change that satisfies one can leave the other broken, most commonly under --ssr where Node’s own require/import resolution re-enters the picture.

Gotchas & edge cases

Four named-export edge cases A transitive dep may not be auto-discovered so add it explicitly; a re-exported barrel hides the CJS source; TypeScript types can declare exports the runtime assigns dynamically; and interop auto fixes the default not the named list. transitive dep not discovered a dep imported only by another may be missed — add it explicitly to include. barrel hides the CJS source the lexer scans the ESM barrel and still misses members — pin the original. types lie about runtime shape a .d.ts can declare exports the runtime assigns dynamically — trust the object. interop: 'auto' ≠ named list it fixes 'default' not exported, not the dynamic members — still destructure.
Figure: two discovery traps and two "the fix you reached for doesn't cover this" traps.
  • optimizeDeps auto-discovery misses transitive and lazily-imported deps. Vite discovers dependencies by crawling from your source entry points, so a CJS package reached only through another dependency, or only via a dynamic import() on a route that has not been visited, may never enter the scan and therefore never get pre-bundled. The symptom is intermittent: the error appears the first time you navigate to the code path, then a mid-session re-optimization reloads the page. Add the package explicitly to optimizeDeps.include so it is bundled up front regardless of how it is reached; confirm by checking that a file for it exists under .vite/deps before you exercise the route.
  • Re-exported barrels hide the CJS source. If quirky-cjs is re-exported through an ESM index.js barrel — export * from 'quirky-cjs' — the lexer scans the ESM barrel, sees a re-export it cannot resolve statically, and still misses the underlying dynamic members. Adding the barrel to optimizeDeps.include does not help because the gap is one level down. Pin the original CJS package by name so esbuild executes it directly, and import from the barrel as usual once the underlying member has been synthesized into the pre-bundle.
  • TypeScript types lie about runtime shape. A hand-written or generated .d.ts frequently declares export function format(...) for members the CJS runtime attaches dynamically. The types compile cleanly and your editor autocompletes the member, which makes the load-time failure especially confusing — the tooling insists the export exists. The declaration describes the intended API, not what the lexer can prove, so trust the runtime object and the generated pre-bundle export line over the .d.ts when they disagree. Fixing this is a build/interop change, never a types change.
  • 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. It is easy to conflate the two errors because both mention exports and interop, but they are distinct: interop controls how a default import is synthesized from a CJS namespace, while this error is about named bindings that were never enumerated. Applying an interop change and expecting the named import to start working is a common dead end. See how to configure ESM and CJS interop in Vite for the full set of Vite overrides.