Export Maps

Export Maps and Conditional Exports

The "main" field names one entry file and leaves every other file in the package publicly reachable. "exports" replaces it with a map: only the subpaths you list can be imported, and each may resolve to a different file depending on conditions. Encapsulation lets you move src/internal/cache.js without breaking a stranger's build; conditions are how one tarball serves both module systems.

node_modules/metrics/package.json -- an export map with conditionsJSON
{
  "name": "metrics", "version": "2.0.0", "type": "module",
  "exports": {
    ".": { "types": "./src/index.d.ts", "development": "./src/index.dev.js",
           "default": "./src/index.js" },
    "./codecs/*": "./src/codecs/*.js",
    "./package.json": "./package.json"
  }
}
What consumers can and cannot reachJavaScript
import { mode } from 'metrics';
import { name } from 'metrics/codecs/gzip';
console.log(mode, '|', name);
try { await import('metrics/src/secret.js'); }
catch (err) { console.log(err.code); }
Output
production build | gzip codec
ERR_PACKAGE_PATH_NOT_EXPORTED

Run the same file as node -C development app.js and the first line becomes development build | gzip codec. The -C (or --conditions) flag adds custom names to the defaults and has been unflagged since Node 22.9.0. Conditions match in the order you write them, first match wins, so "default" must come last and "types" first. The names Node resolves itself are fixed; everything else is a convention that tools opt into.

Conditions Node.js 2,131 resolves on its own
Condition Matches when
"node" Running in Node.js, either module system
"import" Reached by import or import()
"require" Reached by require()
"module-sync" Reached either way, if the graph has no top-level await
"node-addons" Node.js with native addon loading allowed
"default" Always; the required fallback

Pattern keys such as "./codecs/*" are the way to expose a directory: the * is substituted literally and may span several path segments. Export "./package.json" too -- bundlers, linters and version checkers read it, and an export map otherwise hides it.

For a package serving both loaders, "module-sync" is the modern answer and the reason require(esm) exists (Requiring an ES Module): ship one ES module, list it there, and require() and import load the same instance. Separate "require" and "import" targets load two copies with separate module-level state. That is the dual package hazard, and it breaks singletons such as database connection pools.