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.
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
- Confirm the dependency is CJS. Check its
package.json: no"type": "module", a"main"entry, andmodule.exportsin the source. A pure-ESM package would not hit this path. - Confirm the default import works. Temporarily switch to
import pkg from 'quirky-cjs'and logpkg.format. If the member exists at runtime, the failure is purely a detection gap, not a missing export. - Read the lexer output. Vite uses
es-module-lexerandcjs-module-lexerduring pre-bundle. The members detected are exactly those assignable tomodule.exports.<name>orexports.<name>via static literals; loop-, computed-, orObject.assign-based exports are not detected. - Inspect the pre-bundle. Open
node_modules/.vite/deps/quirky-cjs.jsand look at the trailingexport { 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
optimizeDepsis dev/SSR only by default for transitive deps. A package imported only by another dependency may not be discovered; add it explicitly toincluderather than relying on auto-discovery.- Re-exported barrels hide the CJS source. If
quirky-cjsis re-exported through an ESMindex.jsbarrel, the lexer scans the barrel and still misses the underlying CJS members — pin the original package inoptimizeDeps.include. - TypeScript types lie about runtime shape. A
.d.tscan 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’soutput.interopresolves'default' is not exportedbut 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.
Related
- Understanding ESM vs CommonJS in Modern Bundlers — the condition-matching and dual-package context for this error.
- How to configure ESM and CJS interop in Vite —
optimizeDeps,ssr.noExternal, and Rollupinteropsettings in depth. - Core Concepts of Modern Bundling — how the resolver and pre-bundler enumerate module exports.