A package with a growing set of plugins, locales, or adapters faces a small maintenance problem: every new module needs a new exports entry, and forgetting one produces ERR_PACKAGE_PATH_NOT_EXPORTED for a file that plainly exists in the tarball. A single pattern entry replaces the whole list:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './plugins/parquet' is not
defined by "exports" in /app/node_modules/@scope/my-library/package.json

The * token in a subpath key is not a glob and does not behave like one, which is where most of the confusion around it comes from. This guide covers what it actually does, the three ways it is commonly written incorrectly, and how to verify a pattern resolves for runtime and type consumers.


Root Cause

Node.js resolves a bare specifier by finding the longest matching key in the exports map. When the matched key contains a *, the resolver captures the portion of the specifier that the star stands in for, then substitutes that captured string into the target path — textually, once per occurrence of * in the target. There is no globbing, no filesystem scan, and no enumeration: the resolver builds one candidate path and either the file exists or resolution fails.

That design explains every behaviour that surprises people. The star matches slashes because it is a plain capture, not a path segment matcher. Only one star is permitted because there is only one captured value. And a pattern cannot list what it exposes, because nothing ever scans the directory.


Minimal Reproduction

A package with three plugins and an exports map that lists two of them:

{
  "name": "@scope/my-library",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" },
    "./plugins/csv": { "types": "./dist/plugins/csv.d.ts", "import": "./dist/plugins/csv.mjs", "default": "./dist/plugins/csv.mjs" },
    "./plugins/json": { "types": "./dist/plugins/json.d.ts", "import": "./dist/plugins/json.mjs", "default": "./dist/plugins/json.mjs" }
  },
  "files": ["dist"]
}
dist/plugins/csv.mjs
dist/plugins/json.mjs
dist/plugins/parquet.mjs      ← shipped, but unreachable
// consumer.ts
import { read } from "@scope/my-library/plugins/parquet";   // throws at resolution time

The file ships. The import fails. Every new plugin repeats the problem until the map is written as a pattern.


How the Substitution Works

One pattern, three resolutions The pattern key ./plugins/* captures csv, json and aws/s3 from three different consumer specifiers, and substitutes each captured value into the target path to produce three distinct files. "./plugins/*" → "./dist/plugins/*.mjs" "my-lib/plugins/csv" ./dist/plugins/csv.mjs "my-lib/plugins/json" ./dist/plugins/json.mjs "my-lib/plugins/aws/s3" ./dist/plugins/aws/s3.mjs The third row is the one people do not expect — the star captures slashes too

Step-by-Step Fix

1. Replace the enumerated entries with one pattern

   "exports": {
     ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" },
-    "./plugins/csv": { "types": "./dist/plugins/csv.d.ts", "import": "./dist/plugins/csv.mjs", "default": "./dist/plugins/csv.mjs" },
-    "./plugins/json": { "types": "./dist/plugins/json.d.ts", "import": "./dist/plugins/json.mjs", "default": "./dist/plugins/json.mjs" }
+    "./plugins/*": {
+      "types": "./dist/plugins/*.d.ts",
+      "import": "./dist/plugins/*.mjs",
+      "require": "./dist/plugins/*.cjs",
+      "default": "./dist/plugins/*.mjs"
+    }
   }

Every condition target needs its own *. A types target written without one — "types": "./dist/plugins/index.d.ts" — resolves every plugin’s types to the same file, which type-checks against the wrong module and produces errors that make no sense next to working runtime behaviour.

2. Keep the pattern’s directory free of internal modules

A pattern exposes whatever exists at the substituted path. If dist/plugins/ also contains a shared _registry.mjs, then my-lib/plugins/_registry is now importable — the pattern has no way to exclude it.

  dist/plugins/csv.mjs
  dist/plugins/json.mjs
- dist/plugins/_registry.mjs
+ dist/internal/plugin-registry.mjs

Move shared internals outside the pattern’s reach, and reference them with an internal imports specifier rather than a relative path if the layout is likely to change again.

3. Add an explicit entry where one member needs different treatment

Specific keys beat patterns, which is exactly what you want for a deprecated member that should resolve to a compatibility shim:

{
  "exports": {
    "./plugins/legacy-xml": {
      "types": "./dist/shims/legacy-xml.d.ts",
      "import": "./dist/shims/legacy-xml.mjs",
      "default": "./dist/shims/legacy-xml.mjs"
    },
    "./plugins/*": {
      "types": "./dist/plugins/*.d.ts",
      "import": "./dist/plugins/*.mjs",
      "default": "./dist/plugins/*.mjs"
    }
  }
}

Key order in the JSON does not decide this — specificity does — so the explicit entry may appear before or after the pattern.

4. Export a manifest if consumers need to discover members

Because nothing enumerates the directory, a consumer cannot ask which plugins exist. If that matters, generate a small module at build time and export it explicitly:

// scripts/generate-plugin-manifest.ts
import { readdirSync, writeFileSync } from "node:fs";

const names = readdirSync("dist/plugins")
  .filter((f) => f.endsWith(".mjs"))
  .map((f) => f.replace(/\.mjs$/, ""));

writeFileSync(
  "dist/plugins-manifest.mjs",
  `export const PLUGIN_NAMES = ${JSON.stringify(names.sort(), null, 2)};\n`
);

Verification

# each plugin resolves, including a nested one, and an internal path does not
for p in plugins/csv plugins/json plugins/aws/s3; do
  node --input-type=module -e "await import('@scope/my-library/$p')" \
    && echo "ok: $p"
done

node --input-type=module -e "
  try { await import('@scope/my-library/plugins/_registry'); console.log('LEAK'); }
  catch (e) { console.log('blocked:', e.code); }
"

npx attw --pack .

Expected: three ok: lines, one blocked: ERR_MODULE_NOT_FOUND, and an attw table with no red entries for the pattern’s members.


Edge Cases / Gotchas

  • A pattern with no matching file fails at import, not at publish. publint cannot enumerate what a pattern might match, so a typo in the target (./dist/plugin/*.mjs for a plugins/ directory) passes every static check and fails for every consumer.
  • Bundlers implement patterns with slight differences. Vite and webpack 5 both honour them, but older webpack versions and some framework wrappers resolve only explicit keys, silently falling back to main. Test a bundled consumer, not only a Node.js one.
  • typesVersions does not understand patterns. Consumers on legacy moduleResolution: node see no types for any pattern member; giving them coverage needs a typesVersions fallback with its own glob syntax.
  • Two stars in one key is invalid. "./plugins/*/*" will not resolve, and the failure mode differs between runtimes, which makes it look like an environment problem rather than a manifest one.
  • The captured value can contain .. in some tooling. Node.js normalises and rejects traversal attempts, but a bundler with a permissive resolver may not; never build a pattern target that concatenates the capture into a path outside your package directory.

Frequently Asked Questions

Does the star in an exports pattern match across directory separators?

Yes. Unlike a shell glob, the star in a subpath pattern captures everything after the prefix including slashes, so ./plugins/* matches plugins/csv and also plugins/aws/s3. If you want to restrict depth you cannot express that in the pattern itself; use explicit subpath entries for each supported path instead.

Can I use more than one star in a single pattern?

No. Exactly one star is allowed in the key, and the same captured value is substituted into every occurrence of the star in the target. A key containing two stars is invalid and Node.js will refuse to resolve it, while some bundlers silently ignore the entry, which makes the failure look environment-specific.

How do I stop a pattern from exposing files I did not intend to publish?

A pattern exposes whatever exists at the substituted path in the published tarball, so control it with the files array rather than with the pattern. Keep the pattern’s target directory limited to files that are safe to expose, and put internal modules in a different directory that no pattern points at.

Why does my pattern work in Node.js but fail to type-check?

TypeScript resolves patterns only under moduleResolution node16, nodenext or bundler, and only when the pattern includes a types condition whose target also contains the star. A pattern with runtime conditions but no types entry resolves at runtime and appears untyped to the compiler.

Can a pattern and an explicit subpath coexist for the same prefix?

Yes, and the explicit entry wins. Node.js prefers the most specific match, so an entry for ./plugins/csv takes precedence over ./plugins/* for that exact specifier. This is the supported way to give one member of a pattern-managed directory a different target.



↑ Back to Subpath Exports and Deep Import Patterns