Configuring package.json exports for web components
An ambiguous exports map is the most common reason a web component library type-checks in one consumer, fails in another, and serves the wrong file from a CDN — all from the same published tarball. This deep-dive traces one broken map through the Node resolution algorithm, then builds the production-safe conditional and subpath configuration that resolves identically everywhere.
This is a deep-dive under Packaging & Publishing, itself part of the Distribution, Testing & Tooling section. It assumes you already ship ESM-only output and want the entry-point map to be correct.
Problem statement: a map that resolves differently per tool
Here is a real-world exports map that looks complete and passes a casual npm pack review, but is broken in three subtle ways:
{
"name": "@acme/components",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./button": "./dist/button.js"
}
}
The failures:
# 1. Types fall back to `any` under modern resolution
$ tsc --moduleResolution bundler
# editor shows: AcmeButton: any (no autocompletion, no prop types)
# 2. The package.json itself is unreachable
$ node -e "require.resolve('@acme/components/package.json')"
# Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package.json'
# is not defined by "exports"
# 3. ./button has no types condition — consumers of the subpath get `any`
$ attw --pack
# ./button 🟥 No types
The map works — import { AcmeButton } from '@acme/components' runs at runtime — which is exactly why these bugs ship. Runtime success masks resolution failures that only the type system and strict tooling surface.
Root cause: the Node exports resolution algorithm
The Node.js package entry-points specification defines exports resolution as an ordered, first-match algorithm with one rule that drives every bug above: conditions are matched in object insertion order, and the first key whose condition is active wins. This is the PACKAGE_EXPORTS_RESOLVE / PACKAGE_TARGET_RESOLVE procedure, and it is not a best-match search — it is a top-to-bottom scan that stops at the first hit.
For bug #1, the "." entry lists import before types. TypeScript, resolving the package, walks the conditions in order. With moduleResolution: "bundler" or "nodenext", the import condition is active, so resolution stops at ./dist/index.js — a JavaScript file — before it ever reaches types. TypeScript then has no declaration file and falls back to any. The spec is explicit that condition order is authoritative, and the TypeScript module-resolution docs state that types must come first so it is matched before any runtime condition resolves to a .js file.
For bug #2, once an exports field exists, it is a complete allowlist: the spec’s PACKAGE_EXPORTS_RESOLVE throws ERR_PACKAGE_PATH_NOT_EXPORTED for any subpath not explicitly enumerated. package.json is a subpath. Before exports existed, every file was reachable; after it exists, nothing is unless listed. Tooling that reads your-pkg/package.json (bundler plugins, publint, framework integrations) breaks.
For bug #3, the "./button" entry is a bare string target, not a conditions object, so there is no types condition for it at all. The runtime file resolves, but no declaration is associated, and attw reports No types.
Production-safe fix: ordered conditions, wildcards, and a package.json export
The corrected map fixes ordering, adds the package.json self-export, gives every subpath its own types condition, and uses a wildcard "./*" so per-component subpaths scale without one entry per file:
{
"name": "@acme/components",
"version": "2.4.0",
"type": "module",
"files": ["dist"],
"sideEffects": ["**/define-*.js"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./button": {
"types": "./dist/button/button.d.ts",
"import": "./dist/button/button.js"
},
"./define/*": {
"types": "./dist/define/*.d.ts",
"import": "./dist/define/*.js"
},
"./package.json": "./package.json"
}
}
What each change does, mapped to the algorithm:
typesfirst in every conditions object. Because resolution stops at the first active condition, listingtypesbeforeimportguarantees TypeScript matches the.d.tswhile runtime resolvers (which ignoretypes) fall through toimport. Theimportcondition stays last among the runtime conditions but aftertypes; if you also need a non-import fallback, append"default"last as a catch-all.- The
"./define/*"wildcard subpath. A*in both the key pattern and the target is a literal substring capture: importing@acme/components/define/buttonsubstitutesbuttoninto./dist/define/button.jsand./dist/define/button.d.ts. One entry covers every registration module. Wildcards are part of the subpath patterns spec and are exact substring replacement, not glob matching — there is no partial matching or directory traversal. "./package.json": "./package.json". Re-exposes the manifest that the allowlist would otherwise block, satisfying tools that read it.
The component class stays in a pure module (./dist/button/button.js) while the registration side effect lives in ./dist/define/button.js, the standard split that lets tree-shaking side-effect-free libraries keep the customElements.define() call. Registration itself is a mutation of the Custom Element Registry & Definition, which is why it must be isolated from the tree-shakeable class export.
Verification
Prove the map resolves correctly with three independent checks before publishing:
# 1. Force a specific condition and confirm the physical file Node selects.
$ node --conditions=import \
--input-type=module \
-e "import.meta.resolve('@acme/components/button')" \
--eval-stdin 2>/dev/null
# Or, simpler, resolve from a scratch consumer:
$ node -e "console.log(require.resolve('@acme/components/button'))"
# → /…/node_modules/@acme/components/dist/button/button.js ✅
# 2. Lint the published shape.
$ npx publint
# All good! (no exports/type/files warnings)
# 3. Resolve types under every TS module-resolution mode.
$ npx @arethetypeswrong/cli --pack
# "@acme/components" node10 node16(cjs) node16(esm) bundler
# . 🟢 🟢 🟢 🟢
# ./button 🟢 🟢 🟢 🟢
# ./define/button 🟢 🟢 🟢 🟢
The --conditions flag is the most direct diagnostic: it lets you simulate exactly which condition a given consumer activates and watch which file Node returns, turning an invisible resolution decision into observable output. A green attw matrix across all four columns is the contract that the map resolves identically for Node, modern TypeScript, and bundlers.
When to use which entry shape
| Scenario | Use | Avoid |
|---|---|---|
| Single top-level entry, ESM-only | "." with types + import |
A bare-string "." (loses the types condition) |
| Many per-component subpaths, predictable layout | "./*" wildcard subpath |
Hand-listing dozens of entries (drifts out of sync) |
| Selective, curated public surface | Explicit per-subpath entries | "./*" exposing internal modules you meant to hide |
| Tools need the manifest | "./package.json": "./package.json" |
Omitting it (breaks publint, plugins) |
| Supporting both ESM and CJS consumers | A thin CJS wrapper that import()s the ESM |
Duplicate CJS+ESM builds (dual-package hazard) |
| Conditional dev/prod builds | "development"/"production" conditions before default |
Putting default first (it always matches, shadowing the rest) |
The single rule that prevents most mistakes: order conditions from most-specific to least-specific, with types always first and default always last. Because the algorithm stops at the first match, any broad condition placed early silently shadows everything after it.
Related
- Packaging & Publishing — the parent topic covering the full package contract, provenance, and CDN distribution.
- Tree-shaking side-effect-free component libraries — how the pure-vs-define split that this map encodes interacts with
sideEffects. - Distribution, Testing & Tooling — the grandparent section on shipping and proving components.
- Custom Element Registry & Definition — why the registration target must be isolated in its own subpath.