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.
{
"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"
}
}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); }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.
| 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.