Exporting Subpath Patterns with Wildcards
Use the * substitution in package.json exports to expose a directory of modules, including nested paths, per-condition targets, and the gotchas that break resolution.
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
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.
publintcannot enumerate what a pattern might match, so a typo in the target (./dist/plugin/*.mjsfor aplugins/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. typesVersionsdoes not understand patterns. Consumers on legacymoduleResolution: nodesee no types for any pattern member; giving them coverage needs atypesVersionsfallback 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.
Related
- Subpath Exports and Deep Import Patterns — the surrounding design decisions this pattern syntax serves.
- Blocking Deep Imports into dist Folders — the encapsulation side of the same map.
- Per-Subpath Types and typesVersions Fallbacks — giving pattern members types under legacy resolution.