A library grows a src/internal/ directory, relative imports become ../../internal/format.js, and someone reaches for tsconfig.json paths to clean them up. The aliases work through development and the build succeeds — then a consumer installs the package and gets:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/internal/format' imported from
/app/node_modules/@scope/my-library/dist/date.mjs

paths is a compile-time convenience that TypeScript never rewrites into the emitted output. Node’s imports field solves the same readability problem at a level that survives publishing, because it travels in the package manifest and is resolved by the runtime itself.


Root Cause

TypeScript’s paths tells the type checker where to look for a module; it does not change the specifier the compiler emits. When the package is installed elsewhere, the emitted import "@/internal/format" is resolved by Node.js, which has never heard of your alias and has no tsconfig.json to consult — the file is not published, and would not apply to a consumer if it were.

The imports field inverts that. It lives in package.json, ships in the tarball, and is consulted by the runtime for any specifier beginning with # that appears in a file inside that package. The alias is resolved where the code runs rather than where it was compiled.


Minimal Reproduction

{
  "name": "@scope/my-library",
  "type": "module",
  "exports": { ".": "./dist/index.mjs" },
  "files": ["dist"]
}
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] },
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "./dist"
  }
}
// src/date.ts — compiles cleanly, and ships broken
import { pad } from "@/internal/format";

export const formatISO = (d: Date): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
// dist/date.mjs — the emitted output, specifier untouched
import { pad } from "@/internal/format";

tsc --noEmit passes. npm pack succeeds. The package fails on first import in any consumer’s project.


Where Each Mechanism Resolves

paths versus the imports field A tsconfig paths alias is resolved by the type checker in your repository and is absent from the consumer's environment. An imports field specifier is resolved by Node.js in the consumer's installed copy, because the map ships inside package.json. Same convenience, resolved in two different places tsconfig paths resolved in: your repository available to the consumer: no emitted specifier: unchanged needs a rewrite step to be safe imports field resolved in: the consumer's install available to the consumer: yes emitted specifier: unchanged, and fine no build-time cooperation needed

Step-by-Step Fix

1. Declare the internal map in package.json

   {
     "name": "@scope/my-library",
     "type": "module",
     "exports": { ".": "./dist/index.mjs" },
+    "imports": {
+      "#internal/*": "./dist/internal/*.mjs"
+    },
     "files": ["dist"]
   }

The target points at the published layout (./dist/internal/...), not at your source tree, because the map is read after installation.

2. Rewrite the specifiers in source

- import { pad } from "@/internal/format";
+ import { pad } from "#internal/format";
# mechanical, and worth doing in one commit
grep -rl '"@/internal/' src/ | xargs sed -i 's|@/internal/|#internal/|g'

3. Remove the paths entry so the two cannot diverge

   "compilerOptions": {
-    "baseUrl": ".",
-    "paths": { "@/*": ["./src/*"] },
     "module": "nodenext",
     "moduleResolution": "nodenext"
   }

TypeScript resolves #internal/format through the imports map directly under nodenext, so the alias keeps working in the editor with no compiler configuration at all. Leaving both mechanisms in place is the worst outcome: they can disagree, and the one that wins depends on which tool is asking.

4. Add conditional internals where the implementation differs by environment

An internal specifier can carry conditions, which is the cleanest way to ship two implementations of a private module without exposing either publicly:

{
  "imports": {
    "#internal/*": "./dist/internal/*.mjs",
    "#crypto": {
      "node": "./dist/internal/crypto.node.mjs",
      "browser": "./dist/internal/crypto.browser.mjs",
      "default": "./dist/internal/crypto.node.mjs"
    }
  }
}
// src/sign.ts — one specifier, two implementations, neither of them public
import { digest } from "#crypto";
A conditional internal specifier The specifier hash crypto resolves to the Node implementation when the node condition is active and to the browser implementation when a bundler targets the browser, while remaining unreachable from outside the package. One private specifier, two implementations import "#crypto" written once in src/ node condition active dist/internal/crypto.node.mjs browser condition active dist/internal/crypto.browser.mjs Neither file appears in "exports", so neither is importable by a consumer

Verification

# the emitted output contains no compile-time alias
grep -rn 'from "@/' dist/ && echo "FAIL: alias leaked" || echo "OK: no aliases in dist"

# the internal specifier resolves in a real install
npm pack --silent >/dev/null && d=$(mktemp -d) && mv *.tgz "$d/" && cd "$d" \
  && npm init -y >/dev/null && npm install ./*.tgz --silent \
  && node --input-type=module -e "
       const m = await import('@scope/my-library');
       console.log('ok:', typeof m.formatISO);
     "

# the map survived into the packed manifest
tar -xzOf "$d"/*.tgz package/package.json | grep -q '"imports"' \
  && echo "OK: imports field published" || echo "FAIL: imports missing from tarball"

Expected: OK: no aliases in dist, ok: function, OK: imports field published.


Edge Cases / Gotchas

  • Bundlers resolve # specifiers only if they implement the field. Vite, webpack 5 and Rollup’s node-resolve plugin do; a few older or bespoke resolvers do not, and will report the specifier as an unresolvable bare import.
  • # is not valid in a paths key. If you keep any legacy tooling that needs paths, the two mechanisms cannot share a prefix, which is a useful forcing function for removing one of them.
  • Conditional internals still need every target shipped. A browser branch pointing at a file excluded by files fails only for browser consumers, which is the hardest audience to notice in a Node-based test suite.
  • Internal specifiers do not appear in attw output. The tool reports on the public surface, so a broken internal map surfaces as a runtime failure rather than a type-resolution warning — the sandbox install is what catches it.
  • A monorepo’s shared config may inject paths globally. Extending a base tsconfig.json that defines paths re-enables the mechanism you removed; check the effective config with tsc --showConfig rather than reading the file you edited.

Frequently Asked Questions

Why must internal specifiers start with a hash?

The prefix is what tells the resolver that a specifier is internal rather than a package name. Without it there would be no way to distinguish an alias called utils from a dependency called utils, so Node.js requires the prefix and rejects imports entries whose keys lack it.

Can a consumer import my internal specifiers?

No. The field is only consulted for specifiers used by files inside the package that declares it. A consumer writing #internal/format resolves against their own package’s map, not yours, so your internals stay private with no extra configuration.

Does TypeScript understand the imports field?

Yes, under moduleResolution node16, nodenext or bundler, including conditional entries — so no parallel paths configuration is needed. Under the legacy node mode it does not, which is one more reason to move off that mode for library development.

Can imports entries use wildcards like exports patterns?

Yes, and the substitution behaves identically: one star in the key, captured textually including slashes, spliced into every occurrence of the star in the target. A single #internal/* entry therefore covers a whole private directory.

What happens if the imports field is missing from the published manifest?

Every internal specifier fails at runtime with an error stating the specifier is not a valid internal imports name. This usually happens when a build step rewrites package.json into the output directory and drops fields it does not recognise, so verifying the packed manifest catches it before publishing.



↑ Back to Subpath Exports and Deep Import Patterns