Before a package has an exports map, every file it ships is public whether you meant it to be or not. Consumers find internal modules, import them directly, and their code keeps working — until a refactor moves the file and their build breaks with an error that names your package:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/utils/format.js'
is not defined by "exports" in /app/node_modules/@scope/my-library/package.json

Closing the package is the right thing to do, and doing it abruptly turns your refactor into their outage. This guide covers how to establish the boundary, how to discover what people actually import, and how to run a deprecation window so the break lands on a major version with a documented replacement.


Root Cause

Node’s legacy resolution algorithm treats a package directory as a plain filesystem: require("my-lib/dist/utils/format.js") walks to that path and loads it, and nothing in the package can prevent it. The exports field replaces that behaviour with an explicit allowlist — once present, the resolver consults only the map, and any specifier without a matching key fails regardless of whether the file exists.

That switch is all-or-nothing. There is no partial mode, no warning period built into the runtime, and no way to mark an entry “deprecated but working” other than keeping it in the map. The transition therefore has to be managed in your release process rather than in the manifest.


Minimal Reproduction

A package published without an exports field, and a consumer that has found its way inside:

{
  "name": "@scope/my-library",
  "version": "1.8.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"]
}
// consumer/app.ts — perfectly functional today
import { createClient } from "@scope/my-library";
import { formatBytes } from "@scope/my-library/dist/utils/format.js";  // deep import
import type { InternalOptions } from "@scope/my-library/dist/types.js"; // type-only deep import

Adding a minimal exports map in version 1.9.0 breaks both of the deep imports for every consumer on that line, in a release they had every reason to expect was safe.


The Boundary Before and After

What changes when an exports map appears Without an exports field every file in the tarball is importable, including internal utilities and type modules. With an exports field only the listed subpaths resolve, and everything else raises a resolution error. The same tarball, two very different public surfaces no exports field my-lib ✓ my-lib/dist/index.js ✓ my-lib/dist/utils/format.js ✓ my-lib/dist/types.js ✓ every rename is a breaking change whether you intended it or not with an exports field my-lib ✓ my-lib/utils ✓ (deliberate) my-lib/dist/utils/format.js ✗ my-lib/dist/types.js ✗ internal layout becomes yours again rename freely inside the boundary

Step-by-Step Fix

1. Discover which deep paths are actually in use

Guessing produces either an over-generous map that defeats the purpose or an over-tight one that breaks people. Three sources cover most of it: a code-host search for "@scope/my-library/ (with the trailing slash), your own issue tracker, and any first-party framework adapters that wrap the package.

# your own repos and examples, as a starting point
grep -rn --include='*.ts' --include='*.tsx' --include='*.js' \
  "@scope/my-library/" . | grep -v node_modules | sort -u

Expected result: a list of maybe three to ten distinct paths, most of them the same one or two files.

2. Ship the map with compatibility entries in a minor release

Publish the map so that everything currently working keeps working. The public entries are the ones you intend to keep; the compatibility entries reproduce the old deep paths and are marked for removal.

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

    "./dist/utils/format.js": "./dist/utils.mjs",
    "./dist/types.js": "./dist/index.mjs"
  }
}

The last two entries are deliberately unattractive — they exist to be deleted. Their presence means the minor release breaks nobody while the map is in place and the new ./utils subpath is documented.

HAZARD PREVENTION

Symptom: After adding the map, a consumer reports that import type { InternalOptions } from "@scope/my-library/dist/types.js" now fails, even though a compatibility entry exists for that exact path.

Root cause: The compatibility entry has no types condition, so the runtime resolves but TypeScript does not. Type-only deep imports are the most commonly forgotten half of this migration precisely because they never appear in a runtime test.

Fix: Give every compatibility entry the same condition object shape as a real one, types first — and better, re-export the type from your root entry so consumers stop needing the deep path at all.

3. Announce the replacement where the error will send people

The runtime error names the subpath and the manifest. Someone hitting it searches for that subpath and your package name, so the replacement needs to be findable in exactly that search: a changelog entry naming both the old path and the new one, and a line in the README’s migration section.

### Deprecated

Deep imports into `dist/` are deprecated and will stop resolving in v2.0.0.

    - import { formatBytes } from "@scope/my-library/dist/utils/format.js";
    + import { formatBytes } from "@scope/my-library/utils";

4. Remove the compatibility entries in the next major

     "./package.json": "./package.json",
-
-    "./dist/utils/format.js": "./dist/utils.mjs",
-    "./dist/types.js": "./dist/index.mjs"
   }

At this point the internal layout is genuinely yours: files under dist/ that no entry points at can be renamed, merged, or deleted without a semver event.


Verification

# public subpaths resolve
node --input-type=module -e "await import('@scope/my-library/utils'); console.log('ok: utils')"

# internal paths are closed
node --input-type=module -e "
  try { await import('@scope/my-library/dist/internal/cache.mjs'); console.log('LEAK'); }
  catch (e) { console.log('blocked:', e.code); }
"

# the manifest is still readable by tooling
node -e "console.log('manifest ok:', require('@scope/my-library/package.json').version)"

Expected: ok: utils, blocked: ERR_PACKAGE_PATH_NOT_EXPORTED, and a printed version number.


Edge Cases / Gotchas

  • Jest’s default resolver historically ignored exports. A consumer on an older Jest can deep-import successfully in tests and fail in their build, which reads as your package being inconsistent rather than their resolver being out of date.
  • require of a nested path can behave differently from import. Both consult the map in modern Node.js, but a CommonJS consumer bundled by an old toolchain may bypass it entirely; verify both when you check that the boundary holds.
  • Type-only deep imports are invisible to runtime tests. Add a compile check to the verification step, not just an import, or the migration will look complete while every TypeScript consumer is still broken.
  • A files array is not an access control mechanism. Excluding a file from the tarball removes it entirely, which breaks your own runtime imports of it; encapsulation is the exports map’s job, packaging is files’ job.
  • Framework wrappers can re-expose what you closed. If a first-party adapter package re-exports your internals, closing your package moves the problem into the adapter rather than solving it — close both in the same release.

Frequently Asked Questions

Is adding an exports field to an existing package a breaking change?

Yes, for any consumer who imports a path other than the ones you list. Before the field exists every file in the tarball is reachable; the moment it exists only listed subpaths resolve. Treat the addition as a major release, or ship a transitional map that lists the old paths alongside the new ones.

How do I find out which deep paths consumers actually import?

Search public code hosts for your package name followed by a slash, read your own issue tracker, and check any framework integrations that wrap your package. The list is usually short and dominated by two shapes: a utility that should have been exported, and a type-only module people reached for because the root entry did not re-export a type they needed.

Do bundlers respect exports encapsulation the way Node.js does?

Modern bundlers do. Vite, webpack 5 and Rollup with the node-resolve plugin all honour the map and will fail a build that deep-imports an unlisted path. Older toolchains and some legacy Jest configurations ignore exports entirely, which is why a consumer can report that the same import works in their test suite and fails in their production build.

Can I block deep imports without blocking access to package.json?

Yes, and you should. Add an explicit ./package.json entry to the map. Many build tools read a dependency’s manifest at runtime, and a map that omits it produces errors inside other people’s tooling that name your package but give no useful context.

What error will consumers see, and can I improve the message?

Node.js raises ERR_PACKAGE_PATH_NOT_EXPORTED naming the exact subpath and your manifest path. You cannot customise it, but you can make it unnecessary by mapping the old deep paths to their public equivalents for one major version and documenting the replacement where that error will send people looking.



↑ Back to Subpath Exports and Deep Import Patterns