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.

Condition resolution decision flow For one import specifier, resolvers scan conditions top to bottom and stop at the first match, so the types condition must precede the import condition to avoid resolving a JavaScript file as the type declaration. resolve subpath scan conditions ↓ 1. "types" TS active? → .d.ts ✅ must be first 2. "browser" browser? → ESM 3. "import" ESM load → .js 4. "default" catch-all, last order hazard "import" before "types" → TS stops at .js → types become any first match wins scan stops at hit most-specific first, default always last

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 worksimport { 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:

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.

What an exports map blocks and what it must therefore expose Adding an exports field blocks every path not listed, so generated artefacts such as the manifest and editor data need explicit entries to remain readable. An exports map is an allowlist, not a hint listed subpath — importable unlisted file — unreachable, even if published custom-elements.json — needs its own entry editor custom data — needs its own entry The upside of the restriction Internal file layout becomes free to change, because no consumer can depend on a path you did not publish.

Frequently Asked Questions

Why must the types condition come first?

Conditions are matched in declaration order and the first match wins. Placed after default, the types entry is never reached, so the JavaScript resolves and the declarations silently do not.

Does adding exports break existing consumers?

It can. Every deep path not listed becomes unreachable, so a consumer importing an internal file gets a resolution error on upgrade. Treat introducing the map — and any later narrowing of it — as a major-version change.

Should generated artefacts be exported?

Yes, if tooling needs to read them. A manifest present in the tarball but absent from the map cannot be resolved by a consumer’s tooling, which is the most common reason editor completion silently does not appear.

How do I verify the map is correct?

Pack the tarball, install it into a scratch project, and both import and type-check there. A workspace check resolves through path mapping and will pass for a package that is broken for everyone else.

Subpath design for a component library A root barrel, a per-component implementation entry and a per-component registration entry cover every consumption style without exposing internal layout. Three subpaths cover every way the package is consumed "." — the barrel convenience for demos and small apps; registers everything "./card" the class only — no registration, subclassable "./card/define" the registration — the documented side effect Document which entry has a side effect; a consumer cannot tell from the import alone.

Wildcard subpaths and their tradeoff

A pattern entry such as "./components/*": "./dist/components/*.js" publishes a whole directory in one line, which is convenient while a library is growing and awkward once it is stable. The convenience is that adding a component requires no packaging change; the cost is that every file in that directory becomes public API, including ones added by accident, and narrowing the pattern later is a breaking change for anyone who found them.

The middle ground most libraries settle on is an explicit entry per published component plus one wildcard for a directory that is deliberately open — assets, for instance, or a define/ folder whose contents are all intentionally importable. That keeps the public surface reviewable in the package.json diff, which is where a reviewer will actually notice an accidental export.

Reviewing the map as part of every release, rather than only when something breaks, is what keeps the published surface matching the intended one.

A diff that adds a subpath is easy to review; one that adds a wildcard is easy to approve without noticing what it now covers.