Subpath Exports and Deep Import Patterns
Design package subpath exports: wildcard patterns, per-subpath types, blocking deep imports into dist, and the imports field for internal aliases.
A package with one entry point has one packaging decision to make; a package with subpaths has one per subpath, and each of them is a public API commitment that is easy to make accidentally. Getting the design right decides three things at once: how much of your package a consumer pays for when they import one feature, which internal files you can still rename without a major release, and whether a consumer’s deep import breaks on install or works forever. This guide covers the pattern syntax, the encapsulation boundary, per-subpath type declarations, and the imports field that handles the internal half of the same problem. It assumes you already have a working exports field with a root entry, and applies from Node.js 12.7 onwards, with pattern support requiring Node.js 12.20 or later.
Prerequisites
Canonical Configuration Block
The map below covers the three shapes a real package needs: an explicit root, an explicit named subpath, and a wildcard pattern for a directory whose contents change over time.
{
"name": "@scope/my-library",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./date": {
"types": "./dist/date.d.ts",
"import": "./dist/date.mjs",
"require": "./dist/date.cjs",
"default": "./dist/date.mjs"
},
"./plugins/*": {
"types": "./dist/plugins/*.d.ts",
"import": "./dist/plugins/*.mjs",
"require": "./dist/plugins/*.cjs",
"default": "./dist/plugins/*.mjs"
},
"./package.json": "./package.json"
},
"files": ["dist", "package.json"]
}
Three details in that block are load-bearing. The * is a substitution token, not a glob — whatever the consumer writes after plugins/ is spliced into each target path, so my-lib/plugins/csv resolves to ./dist/plugins/csv.mjs and my-lib/plugins/aws/s3 resolves to ./dist/plugins/aws/s3.mjs. The types condition appears first in every entry, because TypeScript stops at the first match exactly as the runtime does. And ./package.json is exported deliberately: a surprising number of build tools read a dependency’s manifest at runtime, and an exports map that omits it breaks them with an error that names your package but originates elsewhere.
Step-by-Step Implementation
Step 1 — Decide the public surface before writing any JSON
List the entry points you are willing to support for the lifetime of the current major version. Everything not on that list becomes internal, and internal files can be renamed, merged, or deleted freely — which is the entire benefit of the exercise.
A useful test for each candidate: if this file moved next month, would a consumer’s build break? If the answer is yes, it belongs in exports as a documented subpath. If the answer is no, keep it out and keep the freedom.
public → . the main API
public → ./date an optional feature with its own dependencies
public → ./plugins/* a set that grows over time
internal → everything under dist/internal/
Expected result: a short list, usually between one and six entries, plus at most one pattern.
Step 2 — Write explicit entries for the named subpaths
Each named subpath gets its own condition object. Repetition here is correct — a shared object referenced from two subpaths is not expressible in JSON, and copy-paste with different targets is clearer than any abstraction.
{
"exports": {
"./date": {
"types": "./dist/date.d.ts",
"import": "./dist/date.mjs",
"require": "./dist/date.cjs",
"default": "./dist/date.mjs"
}
}
}
node --input-type=module -e "import { formatISO } from '@scope/my-library/date'; console.log(typeof formatISO)"
Expected output: function. If it prints ERR_PACKAGE_PATH_NOT_EXPORTED, the subpath key is missing or misspelled; if it prints ERR_MODULE_NOT_FOUND, the key is right but the target file does not exist in the installed package.
HAZARD PREVENTION
Symptom:
Package subpath './date' is not defined by "exports"— even though./dateis clearly in the map.Root cause: The key was written as
"date"without the leading./. Subpath keys are relative specifiers and the prefix is mandatory; a bare key is silently treated as something else entirely.Fix: Prefix every subpath key with
./, including pattern keys ("./plugins/*", never"plugins/*"), then re-runpublint, which flags this specific mistake.
Step 3 — Add a wildcard pattern where the set grows
A pattern is the right tool when the members of a directory change between releases — plugins, locales, icons, adapters — and the wrong tool when there are three stable entries that deserve individual documentation.
{
"exports": {
"./plugins/*": {
"types": "./dist/plugins/*.d.ts",
"import": "./dist/plugins/*.mjs",
"default": "./dist/plugins/*.mjs"
}
}
}
node --input-type=module -e "
const m = await import('@scope/my-library/plugins/csv');
console.log('csv plugin:', Object.keys(m));
"
Expected output: the plugin’s exports. Note what a pattern does not give you: there is no enumeration API, so a consumer cannot discover which plugins exist by inspecting the package — that belongs in your documentation, or in a small manifest module you export explicitly.
Step 4 — Confirm the package is actually closed
Encapsulation is the half of this design that nobody tests, and it is worth one explicit check per release, because a stray entry (or a missing exports map after a build-tool change) silently reopens the whole dist/ directory.
node --input-type=module -e "
try {
await import('@scope/my-library/dist/internal/fmt.mjs');
console.log('FAIL: internal module is reachable');
process.exit(1);
} catch (e) {
console.log('OK: blocked with', e.code);
}
"
Expected output: OK: blocked with ERR_PACKAGE_PATH_NOT_EXPORTED.
Step 5 — Give the internal half the same treatment with imports
Subpath exports control what consumers can reach. The imports field controls how your own files refer to each other, and it is the runtime-resolved alternative to compile-time paths aliases that never survive publishing.
{
"imports": {
"#internal/*": "./dist/internal/*.mjs",
"#env": {
"node": "./dist/env.node.mjs",
"browser": "./dist/env.browser.mjs",
"default": "./dist/env.node.mjs"
}
}
}
// dist/date.mjs — resolved by Node.js itself, in the consumer's install
import { pad } from "#internal/fmt";
import { readConfig } from "#env";
The # prefix is what marks a specifier as internal, and entries are private by construction: a consumer writing import "@scope/my-library/#internal/fmt" gets an error, because imports is only consulted for specifiers inside the package that declares it. The conditional form shown for #env is the cleanest way to ship environment-specific internals without exposing two builds in your public surface.
HAZARD PREVENTION
Symptom:
Invalid module "#internal/fmt" is not a valid internal imports specifier namewhen running the published package.Root cause: The
importsmap is missing from the publishedpackage.json— typically because the build rewrites the manifest intodist/and drops fields it does not recognise.Fix: Verify the packed manifest rather than the source one (
npm pack && tar -xzOf *.tgz package/package.json), and keep manifest generation to tools that preserve unknown fields.
Tooling Validation
# 1. Every exports path exists, conditions are ordered correctly
npx publint --strict
# 2. Every subpath resolves for every consumer mode, against the tarball
npx attw --pack .
# 3. Each public subpath imports cleanly in both module systems
for p in "" "/date" "/plugins/csv"; do
node --input-type=module -e "await import('@scope/my-library$p')" \
&& echo "esm ok: @scope/my-library$p"
done
Sample passing output from attw:
@scope/my-library
"@scope/my-library" 🟢 🟢 🟢
"@scope/my-library/date" 🟢 🟢 🟢
"@scope/my-library/plugins/csv" 🟢 🟢 🟢
No problems found 🌟
A subpath missing from that table entirely is more concerning than one with a warning: it means attw could not resolve it at all, which is what a consumer’s compiler will also conclude.
Compatibility Matrix
| Capability | Node 12.7–12.19 | Node 12.20+ / 14+ | Node 20 LTS | TypeScript 4.7+ | TypeScript 5.x | Bundlers (Vite, webpack 5, Rollup) |
|---|---|---|---|---|---|---|
| Named subpath exports | Yes | Yes | Yes | With node16/nodenext |
Yes | Yes |
Wildcard * patterns |
No | Yes | Yes | With node16/nodenext |
Yes | Yes |
| Trailing-slash directory exports | Deprecated | Removed | Removed | N/A | N/A | Varies — avoid |
imports field (# specifiers) |
No | Yes (14.6+) | Yes | Partial | Yes | Yes |
Conditional imports entries |
No | Yes | Yes | Partial | Yes | Yes |
Per-subpath types condition |
N/A | N/A | N/A | Yes | Yes | Yes |
Naming a Subpath Surface That Ages Well
Subpath names are permanent in a way that internal filenames are not, so the naming decision deserves a few minutes rather than whatever the source tree happens to be called. Four conventions separate maps that stay sensible for years from ones that accumulate apologies.
Name by capability, not by file layout. my-lib/date is a promise about what the module does; my-lib/dist/utils/date-formatting is a description of where a file happened to live when the map was written. The first survives a refactor, the second becomes a lie that you nonetheless have to keep working.
Keep depth to one level wherever possible. my-lib/plugins/csv is fine because the second level is a member of a set. my-lib/react/hooks/forms/validation is four decisions a consumer has to get right, and every one of them is a chance to write a specifier that does not resolve. If a subpath needs that much structure, the material probably wants to be a separate package.
Avoid encoding format or environment in the name. my-lib/esm and my-lib/browser push work onto consumers that conditions already do automatically — and worse, they let a consumer pick the wrong one. Conditions route; names describe.
Reserve the root for the common case. If most consumers import one or two symbols, those belong at the root even if the implementation lives in a subdirectory. A package whose root export is empty, forcing everyone to a subpath, has traded convenience for a symmetry nobody asked for.
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" },
"./date": { "types": "./dist/date.d.ts", "import": "./dist/date.mjs", "default": "./dist/date.mjs" },
"./plugins/*": { "types": "./dist/plugins/*.d.ts", "import": "./dist/plugins/*.mjs", "default": "./dist/plugins/*.mjs" }
}
}
Three entries, one level of depth apart from a pattern-managed set, no formats or environments in the names. A consumer who has seen one of these maps can guess the others.
One further convention is worth adopting explicitly: document each subpath’s cost. A consumer choosing between the root import and a subpath is making a size decision without any information unless you provide it. A short table in the README listing each entry point and what it pulls in turns that guess into a decision, and it costs one build-time measurement per release to keep accurate.
Moving Consumers Between Subpaths
Subpaths get renamed for good reasons — a capability grows into two, a name turns out to be wrong, a set becomes a pattern. Because the specifier is a public API, the move needs the same care as renaming an exported function, and it has a nice property that function renames lack: both names can resolve to the same file for as long as you like.
The sequence is three releases, and only the last one breaks anything:
Minor release: add the new name. Both entries point at the same target. Nothing is deprecated in the manifest, because the manifest has no way to say so — the deprecation lives in the changelog and the documentation.
{
"exports": {
"./date": { "types": "./dist/date.d.ts", "import": "./dist/date.mjs", "default": "./dist/date.mjs" },
"./datetime": { "types": "./dist/date.d.ts", "import": "./dist/date.mjs", "default": "./dist/date.mjs" }
}
}
Any release: make the old name self-announcing. A one-line deprecation notice emitted at import time reaches people who never read changelogs, and can be scoped to development builds so it never appears in production output:
// src/date.ts — the old entry point, kept working
if (process.env.NODE_ENV !== "production") {
console.warn(
'[my-library] "my-library/date" is deprecated and will be removed in v3. ' +
'Import "my-library/datetime" instead.'
);
}
export * from "./datetime.js";
Major release: remove the old name. By this point the warning has been visible for a full release cycle, the changelog entry is searchable, and anyone still on the old specifier gets an error whose message they have already seen.
What makes this work is that the two specifiers resolve to the same module instance, not to two copies. A consumer whose dependency tree contains one library importing my-lib/date and another importing my-lib/datetime gets a single module, shared state included — which is not the case if you implement the alias by duplicating the file. Point both entries at one target and let the resolver do the aliasing.
The same technique covers the other direction: splitting one subpath into two. Keep the original name resolving to a module that re-exports both halves, add the two new names, and remove the combined entry a major later. Consumers who need only one half migrate and get the smaller graph; consumers who need both keep working without changing anything until they choose to.
One practical note on timing: announce the new name in the same release that adds it, not in the release that removes the old one. A deprecation that first appears in the major release which enforces it gives nobody a migration window, and produces the specific complaint that the change was undocumented — even though it was documented, at the wrong moment. The changelog entry that matters is the one shipped a version before the break, while the old specifier still resolves and a consumer can migrate on their own schedule. Treating that ordering as a rule rather than a courtesy also protects you: a maintainer who has to write the deprecation note early is forced to articulate the replacement before the rename lands, and more than one badly-chosen new name has been caught at exactly that moment, before it became permanent and before a single consumer had written it into their imports.
Guides in This Section
- Exporting Subpath Patterns with Wildcards — how the
*substitution works, what it matches, and where a pattern beats explicit entries. - Blocking Deep Imports into dist Folders — closing a previously open package without breaking every consumer at once.
- Using the imports Field for Internal Aliases — runtime-resolved internal specifiers that survive publishing, unlike
paths. - Per-Subpath Types and typesVersions Fallbacks — making every subpath typed for modern and legacy TypeScript resolution.
Related
- Mastering the package.json Exports Field — the condition-matching rules every subpath entry inherits.
- package.json Fields for Distribution — how
files,mainandtypesinteract with the map described here. - Eliminating Barrel File Anti-Patterns — why subpaths often shrink consumer bundles more than any bundler setting.
- Path Mapping and Module Resolution Strategies — the compile-time alias mechanism the
importsfield replaces for published code.