open atlas
↑ Back to track
Node.js, zero to senior NODE · 13 · 02

Package exports and conditions: the public API contract

The exports field is your package's public API contract: it overrides main, blocks every path it does not list, and routes import vs require to different files via conditions — which is also how you create, and avoid, the dual-package hazard of two divergent module instances.

NODE Senior ◷ 20 min
Level
FoundationsJuniorMiddleSenior

You upgrade a dependency by one minor version and your build explodes: Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/parse' is not defined by "exports". The file lib/parse.js is still in node_modules, byte-for-byte unchanged. What changed is that the maintainer added an exports field — and the resolver now treats every path not listed in it as if it does not exist. The same field, used one way, also lets a library load itself twice with two divergent copies of its own state. The exports field is the most consequential nine lines in a modern package.json, and most people never read its rules.

exports overrides main and seals the package

The classic main field names one entry point and leaves the rest of the package wide open: any consumer can require("pkg/src/internal/helper.js") because resolution just walks the filesystem. The exports field inverts that. The moment a package declares exports, three rules take effect at once:

  • It overrides main. Node consults exports first; main is only the fallback for packages without exports.
  • It is an allow-list, not a hint. Any subpath not listed becomes unreachable — ERR_PACKAGE_PATH_NOT_EXPORTED — even if the file physically exists. This is encapsulation: the package author decides the public surface, and internals can be refactored freely because no one could legally import them.
  • You must export package.json explicitly if consumers (or tooling) read it: "./package.json": "./package.json".

Together, these three rules flip the package from open-by-default to sealed-by-contract. Without the third rule, tooling silently breaks; without the second, you have no true API boundary — any deep require("pkg/dist/internal") that worked will keep working, and you cannot safely refactor internals.

{
  "name": "mylib",
  "exports": {
    ".": "./dist/index.js",
    "./package.json": "./package.json"
  }
}

With this, import "mylib" resolves to dist/index.js, and import "mylib/dist/anything-else.js" throws — the deep-import door is shut. That is the senior reason to add exports deliberately: it is the only mechanism Node gives you to draw a real API boundary around a package, turning “every file is public” into “only what I publish is public.”

Subpath exports and patterns

The . key is the package root; additional keys define subpath exports — named entry points consumers reach as pkg/<subpath>:

{
  "exports": {
    ".": "./dist/index.js",
    "./parser": "./dist/parser.js",
    "./utils/format": "./dist/utils/format.js"
  }
}

import "mylib/parser" now maps to dist/parser.js — note the public name (/parser) is decoupled from the physical path (dist/parser.js), so you can move files internally without breaking consumers as long as the map stays stable. For many subpaths, patterns with * avoid listing each by hand. The * is a non-greedy wildcard captured on the left and substituted on the right:

{
  "exports": {
    ".": "./dist/index.js",
    "./features/*": "./dist/features/*.js"
  }
}

Here import "mylib/features/auth" resolves to dist/features/auth.js. Patterns expose a whole directory through one rule, so use them when you genuinely want that subtree public — and remember the encapsulation tradeoff: a broad ./* pattern re-opens the package almost as wide as having no exports at all.

Conditional exports: one specifier, many targets

A subpath’s value can be a conditions object instead of a string: Node walks its keys in order and picks the first whose condition matches the current environment. This is how one package serves ESM to import and CommonJS to require:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}

Order is significant — first match wins, not most-specific — so the conventional ordering is types first (tooling reads it before anything runs), then the runtime conditions, then default last as the catch-all. The common conditions:

ConditionMatches when…Typical target
importLoaded via import / dynamic import()ESM build (.mjs)
requireLoaded via require()CJS build (.cjs)
nodeRunning on Node (any loader)Node-specific code
typesTypeScript resolves the package.d.ts declarations
defaultAlways (final fallback)Safe universal entry
Why this works

Why does types go first when it is not even a runtime condition? Because TypeScript resolves the package at check time, before any code runs, and it stops at the first matching key just like Node does. If import or require sits above types, TypeScript may match the JavaScript target and never find the declarations — so put types at the top of each conditions block. The mirror trap is putting default anywhere but last: because matching is first-wins, a default above require would shadow it and every consumer would get the default target regardless of how they loaded the package.

The imports field: private internal specifiers

Where exports defines the outward API, the sibling imports field defines inward aliases — private specifiers, always prefixed with #, visible only inside the package itself:

{
  "imports": {
    "#db": {
      "node": "./src/db/postgres.js",
      "default": "./src/db/memory.js"
    },
    "#internal/*": "./src/internal/*.js"
  }
}

Inside the package you write import db from "#db" and Node resolves it through the map — with full condition support, so #db can point at a real database on Node and an in-memory stub elsewhere. Unlike a relative path, a # specifier is stable no matter how deep the importing file is nested, and unlike a bare specifier it never escapes to node_modules. It is the clean way to do internal aliasing without a bundler or tsconfig path hackery.

The dual-package hazard

Conditional exports make it easy to ship both an ESM and a CJS build. The trap: if both builds carry state, an application can load both — ESM code reaches the import target, CJS code reaches the require target — and now two separate module instances of your package coexist, each with its own copy of every variable, registry, and instanceof identity. Singletons silently double; an object created by one half fails an instanceof check in the other. This is the dual-package hazard.

// Application accidentally loading both halves:
import { registry } from "mylib";        // → dist/index.mjs instance A
const { registry: r2 } = require("mylib"); // → dist/index.cjs instance B
// registry !== r2  — two states, the hazard

The robust avoidances, in order of preference: (1) ship ESM only and let CJS consumers reach you via dynamic import(), so there is exactly one instance; or (2) keep all stateful logic in a single CJS (or single ESM) core and make the other condition a thin wrapper that re-exports the same instance rather than a second copy. The anti-pattern is two independent full builds that each own state — that is the configuration that bites.

Quiz

After a dependency adds an exports field, require('pkg/lib/parse') throws ERR_PACKAGE_PATH_NOT_EXPORTED, though lib/parse.js still exists. Why?

Quiz

In a conditions block, you place 'default' above 'require'. What happens to CommonJS consumers?

Pick the best fit

You publish a stateful library (it keeps a plugin registry) and want both import and require consumers to work without the dual-package hazard. What do you ship?

Recall before you leave
  1. 01
    Explain what the exports field changes about resolution the moment a package adds it, and why a previously-working deep import can suddenly throw.
  2. 02
    What is the dual-package hazard, how do conditional exports cause it, and how do you avoid it?
Recap

The exports field is the most consequential entry in a modern package.json because it converts a package from an open directory into a sealed public API. The moment it is present it overrides main, and it acts as an allow-list: any subpath not explicitly listed throws ERR_PACKAGE_PATH_NOT_EXPORTED even though the file still exists — which is the encapsulation that lets authors refactor internals safely and is why adding exports can break a long-standing deep import. Subpath exports decouple the public name from the physical path, and * patterns expose a whole subtree through one rule (at the cost of re-opening encapsulation if too broad). A subpath’s value can be a conditions object that Node walks top-to-bottom, first-match-wins, to serve different files to import, require, node, types, and default — so order matters: types first so TypeScript finds declarations, default last as the catch-all. The sibling imports field defines private # specifiers that are internal-only, condition-aware, and stable regardless of file depth. Finally, conditional exports enable the dual-package hazard: two stateful builds loaded together become two divergent instances with separate state and broken instanceof identity. Avoid it by shipping a single instance — ESM-only with dynamic import() for CJS consumers, or a single stateful core that the other condition merely re-exports. Now when you see ERR_PACKAGE_PATH_NOT_EXPORTED after a minor upgrade, you know exactly why: the file is there, but the author added exports and did not list that path — check the exports map, find an exported entry point, and move on.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.