~/blog

Adding "exports" to package.json breaks every deep import

published

#node#npm#typescript

TL;DR

"exports" is not a list of extra entry points. It is an allowlist, and adding it removes every path you did not name — require('pkg/lib/util'), import 'pkg/styles.css', and require('pkg/package.json') all stop resolving. Node throws ERR_PACKAGE_PATH_NOT_EXPORTED. TypeScript’s default moduleResolution does not model "exports" at all, so tsc can pass on code that Node refuses to run.

The problem

You upgrade a dependency to a new minor version. Nothing in the changelog mentions your code. The build breaks:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/util' is not
defined by "exports" in /app/node_modules/pkg/package.json

You did not change the import. The package added an "exports" field.

Every claim below was executed on Node v26.2.0 against a throwaway package whose only export is ".". require('pkg') returns the module; require('pkg/lib/util') and require('pkg/package.json') both throw ERR_PACKAGE_PATH_NOT_EXPORTED, with the subpath quoted back at you in the message.

The same error shows up in three shapes people usually treat as unrelated bugs:

What you wroteWhy it fails once "exports" exists
require('pkg/lib/util')The subpath is not listed in "exports"
import pkgJson from 'pkg/package.json'./package.json is not exported unless listed explicitly
import 'pkg/dist/styles.css'Non-JS assets need their own "exports" entry too

Why it happens

The Node documentation is explicit that this is encapsulation, not addition:

When the "exports" field is defined, all subpaths of the package are encapsulated and no longer available to importers. For example, require('pkg/subpath.js') throws an ERR_PACKAGE_PATH_NOT_EXPORTED error.

And that it takes priority over the older field:

If both "exports" and "main" are defined, the "exports" field takes precedence over "main" in supported versions of Node.js.

Node’s own docs warn package authors about the consequence, in as many words:

Existing packages introducing the "exports" field will prevent consumers of the package from using any entry points that are not defined, including the package.json (e.g. require('your-package/package.json')). This will likely be a breaking change.

So a package can add "exports" in a patch release, stay technically within its own public API, and break yours. From semver’s point of view nothing was removed, because those subpaths were never documented as API. From your point of view a working import disappeared.

The part that makes it hard to catch

TypeScript’s resolution modes disagree about whether "exports" exists at all.

moduleResolutionReads "exports"
node10 (formerly node)No
node16 / nodenextYes
bundlerYes

If your tsconfig.json is still on the legacy node10 mode, TypeScript resolves pkg/lib/util by walking the file system, finds node_modules/pkg/lib/util.js, and reports no error. Node then refuses the same specifier at runtime. A green tsc --noEmit is not evidence that your imports resolve.

What to do

If you consume the package, move to the public entry point first:

// before — depends on a path the package never promised
import { format } from 'pkg/lib/util'

// after — the documented entry point
import { format } from 'pkg'

If the symbol genuinely is not exported from the root, the honest options are to open an issue asking for a subpath export, or to vendor the few lines you need. Reaching around "exports" with a bundler alias works until it doesn’t.

Make TypeScript tell you the truth while you are at it:

// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext"
  }
}

If you publish the package, list every subpath you intend to keep supporting, and remember ./package.json:

{
  "name": "my-package",
  "exports": {
    ".": "./lib/index.js",
    "./feature": "./feature/index.js",
    "./package.json": "./package.json"
  }
}

For a directory of files you want to stay reachable, use a subpath pattern instead of writing one line per file:

{
  "exports": {
    "./features/*.js": "./src/features/*.js"
  }
}

The wildcard is a literal * in both the key and the value, and it expands to the matched segment — so pkg/features/parse.js resolves to ./src/features/parse.js. That pattern was executed too, on the same Node v26.2.0: it resolves, while every sibling path outside the pattern still does not.

Caveats

References