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.
answer
- names must be known before running
- a scan of text, not an execution
- dynamic assignment is invisible
- the error class names the phase
- the default binding never depends on detection
basics
~20 sNamed 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.
solid answer
~50 sNode has to know a module's export names at link time, before any code executes, because ESM imports are resolved statically. For a CommonJS dependency there is no declaration list to read, so Node runs a lightweight static scan of the source and exposes whatever recognisable `exports.foo = ...` assignments it finds. Anything attached dynamically — in a loop, behind a condition, via `Object.assign(module.exports, ...)`, or through a computed key — is invisible to that scan, so the named import fails to link even though `require()` sees the property once the module has run. Note the error type: it is a `SyntaxError` at link time, not a runtime `TypeError`. The reliable fix is to take the default import, which is always the whole `module.exports`, and destructure from it: `import pkg from 'legacy-cjs-pkg'; const { parse } = pkg;`.
code
javascript · 10 lines// legacy-cjs-pkg/index.js (CommonJS)
const impl = {
parse(text) { return JSON.parse(text); },
stringify(value) { return JSON.stringify(value); },
};
// dynamic: no statically visible `exports.parse = ...`
for (const name of Object.keys(impl)) {
exports[name] = impl[name];
}go deeper
Know that named imports from a CommonJS package can fail even when the property exists, and that default-importing the module and destructuring from it is the safe workaround.
Explain the phase ordering: linking matches imports to exports before evaluation, so for CommonJS Node relies on a static source scan and misses anything assigned dynamically.
Diagnose from the error class and the require()-versus-import asymmetry, choose between destructuring at the import site and an upstream ESM wrapper, and say what static safety the workaround gives up.
Frame it as the limit of interop: a missing default can be synthesised, a missing name cannot. Set expectations for which dependencies are safe to consume from ESM and when to require a real ESM entry point from a vendor.
## Why linking happens before execution ES module imports are static. The specification requires that a module's import and export names be known from the source text alone, which is what makes early errors, live bindings and static analysis possible. Loading an ES module graph proceeds in phases: resolve specifiers, **link** (match every import to an export in the module that provides it), then evaluate. An unmatched import is caught in the link phase, and the failure is reported as a `SyntaxError` — the same category as a malformed import statement — because as far as the language is concerned the module graph is not well-formed. Nothing in your code has run at that point. ## Where CommonJS breaks the model A CommonJS module declares nothing. It has one mutable `module.exports` value that is built up imperatively while the module runs. There is no list to consult at link time, and Node cannot execute the module first to find out, because linking precedes evaluation. Node's answer is a static scan of the CommonJS source that recognises the common export idioms and produces a best-effort list of names: ```js exports.parse = parse; // detected module.exports.parse = parse; // detected module.exports = { parse, stringify }; // detected Object.defineProperty(exports, 'parse', {}) // detected in common forms ``` Those names are then exposed as named exports of the synthetic module wrapping the dependency. The default export is separate and unconditional: it is always the `module.exports` value, whatever the scan did or did not find. ## The patterns the scan cannot see The scan reads text; it does not run the program. So anything whose export name only exists at runtime is invisible: ```js // none of these produce a detected named export for (const name of Object.keys(impl)) { exports[name] = impl[name]; } Object.assign(module.exports, require('./implementations')); if (process.env.LEGACY) { module.exports.parse = legacyParse; } else { module.exports.parse = modernParse; } const key = 'pa' + 'rse'; exports[key] = parse; ``` Every one of these leaves `module.exports.parse` populated by the time `require()` returns, which is exactly why the `require()` form works and the named import does not. The mismatch is not about correctness of the package; it is about what is knowable before execution. ## Fixing the import site The robust fix is to route through the default export, which never depends on detection: ```js import pkg from 'legacy-cjs-pkg'; const { parse } = pkg; ``` The default import links unconditionally, the destructuring happens after evaluation, and by then the property exists. The cost is that you lose the static guarantee — a typo in `parse` now yields `undefined` at first use rather than a link-time error — so keep the destructuring immediately below the import where it is visible. A second option, in Node, is `createRequire` from `node:module` to obtain a real `require` inside an ES module and call it directly. That is the most literal translation of the working CommonJS code, at the price of introducing a synchronous, Node-only load into an ESM file. The third option is upstream: a package can add detectable assignments, or ship a small ESM wrapper that default-imports its own CommonJS build and re-exports the names explicitly. That is the fix that helps every consumer rather than one import site. ## Reading the failure correctly Two signals identify this quickly. First, the error class. `SyntaxError: The requested module ... does not provide an export named 'x'` is a link-time failure — the process dies before your entry module's first statement, so no logging you added inside the module will appear. A candidate who tries to debug it with print statements has misread the phase. Second, the asymmetry. If `require()` of the same specifier exposes the property, the module is not missing anything; only the static view of it is incomplete. That immediately narrows the cause to detection rather than versioning, resolution, or a bad install. ## The asymmetry worth naming Default-import interop is mechanically solvable: when a default export is missing there is exactly one candidate to synthesise, namely the entire exports value. Named-export interop is not, because there is nothing to guess from — a name that never appears in the source text cannot be conjured at link time. That is why default interop feels like a solved problem while named imports from CommonJS remain best-effort, and it is the deeper point behind this question.
- Why is the failure a SyntaxError rather than a TypeError or a module-not-found error?The specifier resolved fine and the module was found, so it is not a resolution failure. The graph simply cannot be linked: an import has no matching export, which the specification treats as an early, static error in the same class as malformed syntax. It is raised before evaluation, so no module code has run and no runtime type is involved.
- What is the cost of switching to a default import plus destructuring?You give up the static guarantee. A misspelled name that would have failed loudly at link time now silently yields `undefined` and surfaces later as a call on undefined. You also lose live-binding behaviour for that name, since destructuring copies the value at that moment. Keeping the destructuring immediately below the import limits the damage.
- What could the package author do so consumers' named imports link successfully?Either write exports in a statically detectable form — plain `exports.name = ...` assignments or a literal `module.exports = { a, b }` — or publish a thin ESM wrapper that default-imports the CommonJS build and re-exports each name explicitly. The wrapper approach requires no rewrite of the implementation and fixes every consumer at once.
saying these in an interview costs you the question
- Assumes the package version is wrong and upgrades blindly
- Adds console.log inside the module to debug a link-time error
- Thinks Node runs the CommonJS module to list its exports
- Believes named imports from CommonJS never work
- Confuses this with a module resolution failure