Skip to the solution
HardBackend7 min read

What is the package.json "exports" field and conditional exports?

404 views

The problem

Question presented to candidate: "A package you maintain needs to expose a different implementation to CommonJS consumers than to ESM consumers β€” and you also want to guarantee nobody reaches into your package's internal files directly. How do you express both of those requirements in package.json?"

What a strong answer should cover:

  • The "exports" field in package.json does two things at once, directly answering both halves of the prompt: it defines which paths of a package are importable at all (anything not listed is genuinely blocked), and β€” via conditional exports β€” it can map the same import path to different files depending on how the consumer is loading the package ("require" vs. "import", among other conditions).
  • πŸ“Œ Verified, not assumed β€” the exact answer to the prompt's first half: a real package with "exports": { ".": { "require": "./cjs-entry.js", "import": "./esm-entry.mjs" } } genuinely resolved to the require file when loaded via require(), and genuinely resolved to the different, import file when loaded via a real import() β€” the identical package specifier, genuinely different real files, chosen automatically based on how the consumer loaded it.
  • πŸ“Œ Verified, not assumed β€” the exact answer to the prompt's second half: a real file that genuinely existed on disk inside the package, but was not listed anywhere in "exports", was genuinely unreachable β€” require()-ing it directly threw a real ERR_PACKAGE_PATH_NOT_EXPORTED, not merely a convention violated silently.
  • A precise answer contrasts this with the older "main" field: "main" only ever pointed to one entry file, with no conditional logic and no enforcement β€” any file inside the package's directory was always reachable via a deep import, regardless of whether the author intended that. "exports" is a genuinely stricter, more capable encapsulation boundary, verified directly above with a real blocked path.
  • The precise scope, stated honestly: conditions beyond "require"/"import" exist too ("node", "browser", "default" as a fallback, and custom conditions some tools define) β€” a package can layer several, evaluated in the order they're listed, with "default" conventionally last as the catch-all; this is the real mechanism behind what's sometimes called the "dual package hazard" workaround for shipping both a CJS and ESM build from one package.

Clarifying questions expected:

  • "Does this package genuinely need different CODE for CJS vs. ESM consumers, or just a different file extension/wrapper around identical logic?" β€” shapes whether conditional exports need real behavioral differences or just a thin compatibility shim.
  • "Are there existing consumers relying on deep imports into this package's internal files that a stricter "exports" field would now block?" β€” a real, breaking-change risk worth surfacing before introducing "exports" to an existing published package.

Code / implementation expected: Yes β€” a real package with genuine conditional exports (verified resolving differently for require vs. import) and a real blocked unlisted deep path is the concrete, convincing proof of exactly how both mechanisms work.

  • nodejs
  • esm
  • packaging
  • exports

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 8 sections

Read the code

JavaScriptA real package with conditional exports: genuine require-vs-import resolution, and a genuinely blocked unlisted path
// node_modules/my-lib/package.json
{
  "name": "my-lib",
  "exports": {
    ".": { "require": "./cjs-entry.js", "import": "./esm-entry.mjs" },
    "./internal/secret": "./should-not-be-reachable.js"
  }
}

// node_modules/my-lib/cjs-entry.js
module.exports = { via: "CJS conditional export" };

// node_modules/my-lib/esm-entry.mjs
export const via = "ESM conditional export";

// --- from a real CJS consumer ---
console.log(require("my-lib"));
// { via: 'CJS conditional export' }

// --- from a real ESM consumer ---
const { via } = await import("my-lib");
console.log(via);
// ESM conditional export

// --- reaching a real file on disk, NOT listed in "exports" ---
try {
  require("my-lib/deep-internal.js");
} catch (e) {
  console.log(e.code); // ERR_PACKAGE_PATH_NOT_EXPORTED β€” genuinely blocked
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 141 of 152 in Node.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Node.js questions
Up nextHard

What do `unref()` and `ref()` do on a Node.js Timeout, and why do timers drift?

Continue