Using the imports Field for Internal Aliases
Replace tsconfig paths with Node's imports field so internal aliases resolve at runtime in a published package, including conditional internals and TypeScript support.
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
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";
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 apathskey. If you keep any legacy tooling that needspaths, 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
browserbranch pointing at a file excluded byfilesfails only for browser consumers, which is the hardest audience to notice in a Node-based test suite. - Internal specifiers do not appear in
attwoutput. 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
pathsglobally. Extending a basetsconfig.jsonthat definespathsre-enables the mechanism you removed; check the effective config withtsc --showConfigrather 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.
Related
- Subpath Exports and Deep Import Patterns — the outward-facing half of the same design.
- Handling TypeScript Path Aliases in Published Packages — what to do when you must keep
pathsand rewrite specifiers instead. - moduleResolution: bundler vs nodenext — the setting that decides whether TypeScript reads this field at all.