Adding "exports" To Your package.json Is A Breaking Change. Stop Shipping It As A Patch.
Six lines of JSON can make every deep import your consumers rely on throw at runtime, including require('pkg/package.json'). How the exports field really resolves, the TypeScript condition-order bug that fails silently, the dual package hazard, and how to roll it out without breaking anyone.
The Smallest Diff That Can Break A Build
Here is a pull request that gets approved in about forty seconds in most repositories I have worked in. Somebody is tidying up a shared package, they read that modern packages should declare their entry points properly, and they add this:
{
"name": "@acme/ui",
"version": "3.4.2",
"main": "./dist/index.js",
+ "exports": {
+ ".": "./dist/index.js"
+ }
}
It looks like metadata. It looks like the main field wearing a nicer shirt. The version goes to 3.4.3, CI is green because the package's own tests import it by its root name, and it ships.
Then somewhere else, a consumer that has been doing require('@acme/ui/dist/utils/format') for two years picks up the patch through a caret range and gets this:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/utils/format' is not defined by "exports" in node_modules/@acme/ui/package.json
That is not a hypothetical error message. I reproduced it on Node 26 while writing this, with a package exactly that shape. The deep import was never officially supported, sure. Nobody promised it would work. But it did work, people depended on it, and semver is about what you break, not about what you meant.
The exports field is not metadata. It is an access control list for your package, and adding one means deny by default.
I have spent a fair amount of the last few years on frontend infrastructure: monorepos, internal packages, the build and tooling glue in between. This field sits right at the centre of that work, and I think it is one of the most under-respected six lines in the JavaScript ecosystem. Not because it is bad. It is genuinely good. It is just not a cosmetic change, and treating it like one is how you end up with a patch release that breaks builds.
main Was A Suggestion. exports Is A Wall.
Before exports, a package was basically a folder. main told Node which file to load when someone imported the bare package name, and after that, every file inside the folder was reachable by path. lodash/fp/map, some-lib/dist/internal/thing.js, whatever/package.json. If the file existed on disk, you could import it.
The exports field flips that model. Once it exists, it is the complete list of what the outside world may import. Anything not listed is unreachable through the package name, even though the file is sitting right there in node_modules. Two more details that surprise people:
- It overrides
main. When both are present, Node usesexports.mainonly matters to older tools that do not understand the newer field. - It blocks
package.jsonitself.require('your-pkg/package.json')throws unless you export it explicitly, and a surprising number of tools read other packages' manifests that way to check versions or peer dependencies. - It is enforced at resolution time, not install time. Nothing warns you during
npm install. The failure appears the first time the import runs, which might be in a test, a build, or production.
The Node documentation is unusually direct about this. It does not bury it in a footnote:
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.
The bold in the original is Node's, not mine. So the most honest answer to "should adding exports be a major version?" is that the people who designed the field already told you it probably should be.
Why the wall is a good thing
I want to be clear that I am not arguing against the field. I use it on every package I set up now. The wall is the point. Without it, every file in your dist folder is public API whether you like it or not, and you cannot rename an internal module without risking someone's build. With it, you get to decide what is public, and refactoring the inside of a package becomes safe again.
The problem is purely the transition. Going from "everything is public" to "only this is public" is, by definition, removing things from your public surface. That is what a major version is for.
The Object Order Is The Algorithm
The second half of exports is conditional exports: the ability to point the same import at different files depending on who is asking. A typical dual package looks like this:
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.mjs" } }
Here is the thing that catches almost everyone the first time: there is no priority system. The resolver walks the keys in the order they appear in the JSON object and takes the first one whose condition it supports. JSON objects are technically unordered in the spec, but Node reads them in source order and that order is load-bearing.
So this looks harmless:
"exports": { ".": { "default": "./cjs.js", "import": "./esm.mjs" } }
And it is completely wrong. default matches everything, so the import branch is dead code. I tested it: an ES module doing import x from "order" gets the CommonJS file every single time. No warning, no error. Your ESM build is simply never used, and you find out when someone asks why tree shaking stopped working (which, if you have read my tree shaking piece, you know is already fragile enough).
Most specific condition first, default always last. If a condition key appears after default, it can never match.
Conditions are not only import and require
Node itself understands node, import, require, module-sync and default. Bundlers and tools add their own: browser, development, production, types, and a few more depending on which tool you ask. Conditions can also nest, so you can have a browser branch that itself splits on import and require.
This is powerful and I have seen it turn into an unreadable decision tree very quickly. My opinion: if your conditions object needs more than two levels of nesting, the package is trying to be too many things, and you should question the build matrix before you question the JSON.
"types" Goes First, And TypeScript Will Not Tell You Why
This is the part I find most interesting, because the failure mode is silent and the folklore around it is slightly wrong.
You will read everywhere that the types condition must come first. That is good advice. But when I actually tested it with TypeScript 6.0, putting types last did not break things in the simple case. With only a .js file behind the import condition, TypeScript found nothing it could use there and carried on to the types entry. Everything type-checked correctly.
So why does the rule exist? Because of what happens when there is something TypeScript can use behind the earlier condition. I added a stale declaration file next to the JavaScript, the kind of leftover you get when a build output folder is not cleaned between runs:
dist/
esm/
index.js
index.d.ts // old: export function hello(): any;
types/
index.d.ts // current: export function hello(): string;
With "import" listed before "types", TypeScript resolved ./dist/esm/index.js, looked for a sibling declaration file, found the stale one, and used it. hello() came back as any. A line that should have been a type error - assigning that string to a number - compiled without a complaint.
Swap the order so types comes first, and the same code correctly fails with TS2322: Type 'string' is not assignable to type 'number'.
I got the same behaviour under both bundler and nodenext resolution. The dangerous part is not a red squiggle. The dangerous part is the absence of one, in exactly the place you were relying on types to catch a mistake. If you want to see what TypeScript is actually doing, tsc --traceResolution prints every condition it matched, and it is the first thing I reach for when types look suspiciously loose.
The Same Import, Two Different Answers
Here is the other TypeScript wrinkle, and it is one that bites monorepos in particular. The old moduleResolution: "node" setting, now called node10, predates exports and ignores it entirely. It looks at main, types and the file system, like it is 2019.
So I ran the same package against both settings. A package that only declares its types inside exports:
| Import | node10 | bundler / nodenext |
|---|---|---|
from "tl" | TS2307: cannot find module | Resolves correctly |
from "tl/dist/types/index" | Resolves (deep import) | TS2307: cannot find module |
Read that table twice. The import that works under one setting is exactly the import that fails under the other. If a workspace has packages on different tsconfig settings (which, in any monorepo old enough to have history, some of them will be), you can have one app happily compiling against a deep path that another app is not allowed to see.
To TypeScript's credit, the node10 error message is now genuinely helpful. It says there are types at the path you would expect, but they cannot be resolved under your current setting, and suggests switching. And TypeScript 6.0 goes further: using node10 at all now errors with TS5107, a deprecation warning saying it will stop working in TypeScript 7.0, unless you set ignoreDeprecations.
Treat the deprecation as a forcing function. Moving to bundler or nodenext will surface every deep import your code relies on, which is precisely the list you need before anybody adds exports to those dependencies.
Two Copies Of Your Class, And instanceof Lies
Conditional exports make it very easy to ship a package that has both an ESM build and a CommonJS build. That feels like the responsible thing to do. It also creates something the Node docs call the dual package hazard, and it is worth seeing it once with your own eyes.
import { createRequire } from "module"; import { Err } from "dual"; // resolves to esm.mjs const cjs = createRequire(import.meta.url)("dual"); // resolves to cjs.cjs console.log(Err === cjs.Err); // false console.log(new cjs.Err() instanceof Err); // false
Both lines print false. Same package name, same version, two completely separate module instances. Any module-level state exists twice: caches, registries, singletons, React contexts, the lot. An error thrown by one copy is not an instance of the other copy's error class, so your carefully typed catch branch just does not run.
In a single app you control, this is rare. In a real dependency graph it is not, because you do not choose how your dependencies load their dependencies. One library written in CommonJS calls require("dual"), your code does import, and now there are two.
require(esm) changes the calculation
The reason dual packages existed in the first place was that CommonJS could not require() an ES module. That is no longer true. Node unflagged require(esm) in the 22 line and backported it to 20, and on the Node 26 I tested with it simply works: require() on a synchronous ES module returns its namespace object.
Two practical consequences that I think more package authors should know about:
- ESM-only is far more viable than it was. If you only support maintained Node versions, a single ESM build with no top-level await avoids the hazard completely, because there is only one copy to load.
- The
module-synccondition exists for exactly this. It matches whether the package is loaded withimportorrequire(), so both paths can share one ESM file. A module with top-level await in its graph will throwERR_REQUIRE_ASYNC_MODULEwhen required, so this only works for synchronous code. - The namespace shape catches people.
require()gives you the module namespace, so a default export comes back under.defaultrather than as the value itself. Consumers doingconst fn = require("pkg")will need to adjust.
My honest opinion: for a new internal package in a monorepo, I would ship ESM only and not look back. For a public package with a long tail of consumers on old tooling, dual builds are still a reasonable call. Just know you are buying the hazard with them.
Subpath Patterns, null, And The imports Field
Once people discover the wall, the first reaction is often to punch a giant hole back through it. You can export everything with a wildcard, and you can also do the precise opposite and block parts of a wildcard with null:
"exports": { ".": "./dist/index.js", "./*": "./dist/*.js", "./internal/*": null, "./package.json": "./package.json" }
With that shape, pkg/button resolves to dist/button.js, while pkg/internal/x throws ERR_PACKAGE_PATH_NOT_EXPORTED even though the wildcard would otherwise match it. I checked both. The * is a plain string substitution, not a glob, so it can include slashes, which is convenient and occasionally more permissive than people expect.
Note the last line. I add "./package.json": "./package.json" to basically every package now. It costs nothing and it removes one of the most common ways the field breaks unrelated tooling.
The half nobody uses: imports
There is a sibling field, imports, that I think deserves a lot more attention than it gets. It defines private aliases that only work inside the package itself, and every key has to start with #:
"imports": { "#utils/*": "./src/utils/*.js" }
Inside the package, require("#utils/fmt") works. From outside, the same file is still blocked by exports. It is path aliasing that the runtime understands natively, which means no tsconfig paths plugin, no bundler alias config, and no mismatch between the three of them. If you have ever debugged an import that works in the editor, works in tests and fails in production because the aliases were configured in two out of three places, this is the fix.
How I Would Add exports Without Breaking Anyone
None of this means you should avoid the field. It means you should introduce it the way you would introduce any change to a public API. Here is the process I would follow, and the one I push for in reviews.
- Find the deep imports that actually exist. In a monorepo this is a grep away: search every consumer for
from "@scope/pkg/andrequire("@scope/pkg/. For a public package, search GitHub code search. You are building the list of paths people really use, not the list you wish they used. - Export those paths on purpose, at first. The first version of
exportsdoes not need to be the ideal API. It needs to include every path in use, plus./package.json. You can deprecate the ugly ones later, on your schedule. - Verify with a real resolver, not your eyes. Tools like
publintand "Are the Types Wrong?" (@arethetypeswrong/cli) check condition ordering, missing files and type resolution across modes. They catch the class of problems from the TypeScript section before your consumers do. - Bump the major version. Even if your list is complete, you cannot see every consumer. The Node docs call it likely breaking. Believe them.
- Tighten in a later major. Once the field is in and people have moved to the paths you support, removing the compatibility exports is a normal, well-signposted breaking change.
The version number matters less when everything builds together, but the grep does not. Run it before merging, and add publint to the package's CI so a reordered conditions object fails a check instead of silently shipping.
This is one of those places where frontend infrastructure work is mostly about taste. The field itself is simple. The hard part is noticing that a six line diff to a manifest is a contract change, and having the process in place so nobody has to remember that under deadline pressure. It connects directly to what I wrote about the lack of isolation in npm workspaces: hoisting lets code import things it never declared, and exports is one of the few tools that pushes back on that.
Treat Your Manifest Like Code
If you remember nothing else from this, remember the short version:
- Adding
exportsremoves every path you did not list, includingpackage.json. That is a breaking change. - Conditions are matched in object order.
typesfirst,defaultlast. - Putting
typeslate does not always fail. It fails silently when a stray declaration file wins, which is worse. - Dual ESM and CommonJS builds mean two copies of your module. With
require(esm)in current Node, ESM-only is a real option again. - Use
importswith#aliases for internal paths, and let the runtime enforce privacy for you.
I like exports a lot. It is the first time npm packages have had a real, enforced boundary between public and private, and it makes packages easier to maintain over years rather than months. But good boundaries are introduced deliberately. The worst way to adopt the field is the most common one: as a quiet tidy-up, in a patch release, approved in forty seconds.
Your package.json is not config. For everyone who depends on you, it is the API.
--traceResolution or publint before relying on any single claim.Related essays
- CI/CD · Build Systems
Your CI Cache Is a Second Package Manager. Treat It Like One.
A CI cache can cut minutes from a build, or quietly make your release process non-reproducible. The difference is whether you treat it as disposable acceleration or hidden infrastructure.
- Frontend Infrastructure · npm
I Stopped Using npm install. Here's the 40-Second Trick That Changed How I Debug Dependencies.
Most engineers reinstall node_modules when debugging dependency issues. There's a faster way that lets you patch, test, and iterate on third-party packages in seconds - without waiting for CI or publishing to a test registry.
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.