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.


The subpath boundary An exports map defines a boundary: the root entry, a feature subpath and a plugin pattern are reachable from outside the package, while internal helper modules inside the same dist directory are not importable at all. What the exports map lets through consumer code "my-lib" "my-lib/date" "my-lib/plugins/csv" "my-lib/dist/internal/fmt" blocked exports map "." → index "./date" → date "./plugins/*" → plugins/* no entry → no access, whatever the file layout files in the tarball dist/index.mjs dist/date.mjs dist/plugins/csv.mjs dist/internal/fmt.mjs present, but unreachable

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 ./date is 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-run publint, 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"
    }
  }
}
Wildcard substitution in a subpath pattern The consumer specifier my-lib slash plugins slash aws slash s3 matches the pattern key, capturing aws slash s3, which is substituted into every target path in the condition object including the types entry. The star is spliced in, not matched as a glob consumer writes "my-lib/plugins/aws/s3" captured segment * = "aws/s3" — slashes included substituted into every target in the condition object types → ./dist/plugins/aws/s3.d.ts import → ./dist/plugins/aws/s3.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";
exports faces out, imports faces in The exports field is consulted when code outside the package resolves a specifier that starts with the package name. The imports field is consulted only for hash-prefixed specifiers used by files inside the package itself. Two fields, two directions, no overlap "exports" — outward used by: code outside the package specifier: "my-lib/date" changing it is a public API change documented, versioned, permanent "imports" — inward used by: files inside the package specifier: "#internal/fmt" changing it affects nobody outside private by construction

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 name when running the published package.

Root cause: The imports map is missing from the published package.json — typically because the build rewrites the manifest into dist/ 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



Back to Module System Fundamentals