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.