Path Mapping and Module Resolution Strategies
Configure TypeScript path aliases with build-time resolution, map tsconfig paths to package.json exports, and prevent leaked aliases in published packages.
Without a deliberate alias strategy, TypeScript path mappings (compilerOptions.paths) produce JavaScript output containing raw alias strings like @lib/core that Node.js cannot resolve at runtime — causing ERR_MODULE_NOT_FOUND the moment a consumer installs your package. This failure affects every Node.js version from v12 onward, and becomes especially sharp in Node 20+ where native ESM strict resolution drops legacy fallback chains entirely.
Prerequisites
How Compiler and Runtime Resolution Diverge
The diagram below shows how the same import specifier travels through two completely separate resolution pipelines — TypeScript’s compile-time checker and Node.js’s runtime loader — and where aliases can fall through the gap.
TypeScript’s paths directive operates exclusively during type-checking and compilation. It tells the compiler how to locate source files for IntelliSense and type validation, but it performs zero runtime resolution or AST rewriting. Node.js relies on its own native algorithms: ESM uses the exports field in package.json, while CommonJS falls back to the require() resolution order (node_modules, NODE_PATH, relative paths).
The moduleResolution compiler option dictates how closely TypeScript mimics runtime behavior. Legacy node mode ignores conditional exports and extension requirements. Modern node16/nodenext modes enforce ESM resolution rules including explicit .js extensions and strict exports mapping. The bundler mode assumes a downstream tool (Vite, Webpack, esbuild) will handle resolution, which is dangerous for library authors shipping directly to Node.
Canonical Configuration Block
This is the baseline tsconfig.json for a library that uses path aliases safely. Every option is annotated:
{
"compilerOptions": {
// Emit modern ESM-compatible output; pair with "moduleResolution": "Node16"
"module": "Node16",
// Enforce Node 20+ ESM resolution: explicit .js extensions, strict exports mapping
"moduleResolution": "Node16",
// Output goes to dist/ — never ship src/
"outDir": "./dist",
// Generate .d.ts declaration files for consumers
"declaration": true,
// Generate .d.ts.map so IDE "Go to definition" reaches source
"declarationMap": true,
// Generate sourcemaps for debuggability
"sourceMap": true,
// Anchor for paths — use "." (project root), not "src/"
"baseUrl": ".",
// Scoped aliases only — never map bare specifiers
"paths": {
"@lib/*": ["./src/lib/*"],
"@types/*": ["./src/types/*"],
"@utils/*": ["./src/utils/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
HAZARD PREVENTION: Never pair
moduleResolution: "bundler"with a library targeting native Node execution. The compiler silently accepts missing.jsextensions and unexported subpaths, causingERR_MODULE_NOT_FOUNDat runtime. UsemoduleResolution: "Node16"or"NodeNext"for any package shipping to Node.js consumers.
Step-by-Step Implementation
Step 1 — Align moduleResolution with Your Runtime Target
Before setting up aliases, confirm that module and moduleResolution in your tsconfig.json match the environment your consumers will run in. Misalignment here silently accepts invalid imports during development.
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16"
}
}
Expected tsc --noEmit output with a correct configuration (no errors):
# (no output = success)
If you see TS5110: Option 'module' must be set to 'Node16' when option 'moduleResolution' is set to 'Node16', you have a mismatched pair — fix both fields simultaneously.
Step 2 — Scope All Aliases Under a Dedicated Namespace
Maintain aliases under a scoped prefix (@lib/*, @pkg/*, @internal/*) to prevent collision with node_modules packages. Avoid "baseUrl": "src" with a wildcard catch-all, which forces the compiler to resolve every bare import against your source tree first, breaking packages like lodash if a src/lodash.ts happens to exist.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@lib/*": ["./src/lib/*"],
"@types/*": ["./src/types/*"]
}
}
}
Prefer directory-level wildcards (@lib/*) over file-level mappings to reduce configuration drift as your project grows.
HAZARD PREVENTION: Avoid
"baseUrl": "src"with"paths": { "*": ["*"] }. This forces every bare import to resolve againstsrc/beforenode_modules, meaningimport { clone } from "lodash"fails ifsrc/lodash.tsexists. Always scope aliases under a dedicated prefix.
Step 3 — Configure Build-Time Alias Transformation
TypeScript does not rewrite aliases in emitted JavaScript. A build-time AST transformation step is mandatory. Regex-based string replacement is fragile and breaks sourcemaps and declaration files. Use AST-aware plugins instead. The declaration file generation pipeline must also handle alias rewriting in .d.ts output — raw aliases in declaration files break consumer IDE navigation.
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
// tsup resolves paths from tsconfig automatically when dts: true
// For complex alias graphs, add an explicit alias plugin:
esbuildOptions(options) {
options.alias = {
"@lib": "./src/lib",
"@types": "./src/types",
"@utils": "./src/utils",
};
},
});
Expected output after tsup:
CLI Building entry: src/index.ts
CLI Build success in 340ms
dist/index.js 1.2 kB
dist/index.mjs 1.1 kB
dist/index.d.ts 0.8 kB
Verify that no @lib/ strings remain in the output:
grep -r "@lib/" dist/ && echo "LEAKED ALIASES FOUND" || echo "Clean — no aliases in dist/"
HAZARD PREVENTION: Always enable
sourcemap: trueand verify that alias transformations do not stripimport typestatements prematurely. Usetsc --declarationMapalongside your bundler to maintain IDE “Go to definition” navigation in downstream projects.
Step 4 — Map Build Outputs to package.json Conditional Exports
Replace TypeScript-specific aliases with package.json subpath exports so both Node.js and TypeScript consumers resolve paths without any compiler config. This is the production-grade substitute for paths in published packages — it works at runtime without any build tool.
{
"name": "@scope/library",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.cjs"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.mjs",
"require": "./dist/types/index.cjs"
}
}
}
HAZARD PREVENTION: Never omit the
"types"condition fromexports. WithmoduleResolution: "node16", TypeScript will not locate declarations if"types"is absent from the export map, causingCannot find module '@scope/library'errors for consumers even when the runtime import works.
Step 5 — Validate Resolution in CI
Run isolated tsc --noEmit in a clean CI runner without local caches to catch type resolution mismatches. Matrix test across Node.js LTS versions to expose hoisting discrepancies.
# .github/workflows/resolution-validation.yml
name: Resolution Validation
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Type Check (no local cache)
run: pnpm tsc --noEmit --force
- name: Build and Check Output
run: pnpm tsup && grep -r "@lib/" dist/ && exit 1 || echo "No leaked aliases"
- name: Smoke Test Dual Entry
run: |
node --input-type=module -e "import './dist/index.mjs'; console.log('ESM OK')"
node -e "require('./dist/index.cjs'); console.log('CJS OK')"
HAZARD PREVENTION: CI caches often mask
node_modulesresolution failures. Always runpnpm install --frozen-lockfileornpm ciin validation jobs to guarantee a pristine dependency graph that reflects what consumers will actually install.
Hazard Call-Outs
HAZARD PREVENTION: Publishing packages with unresolved
pathsaliases — TypeScript emits raw alias strings (@lib/core) in.jsoutput without runtime transformation, causingERR_MODULE_NOT_FOUND. Fix: rungrep -r "@lib/" dist/as a pre-publish check; add build-time path rewriting viatsup’sesbuildOptions.aliasorrollup-plugin-alias.
HAZARD PREVENTION: Mismatched
moduleResolutionbetween tsconfig and bundler — the compiler accepts missing.jsextensions and unexported paths under"bundler"mode, while the Node.js runtime requires both. Fix: set"moduleResolution": "Node16"intsconfig.jsonand configure your bundler’s resolution fields to match Node’sexports-first lookup.
HAZARD PREVENTION:
ERR_PACKAGE_PATH_NOT_EXPORTEDfrom legacy alias resolution — occurs when consumers attempt to import subpaths not listed inexports, often because an old@lib/corealias was not mapped to a corresponding exports entry. Fix: audit every public alias and add a matchingexportskey; runpublintto surface unmapped subpaths before publishing.
HAZARD PREVENTION: ESM runtime fails to resolve
.jsextensions from.tssources — Node ESM requires explicit extensions; TypeScriptpathsdoes not auto-append them. Fix: write source imports asimport { x } from "./lib/core.js"(not.ts) when targeting native ESM, and configureexportsconditions explicitly for each subpath.
Tooling Validation
Run these commands after every build to verify the configuration is correct before publishing:
# 1. Type-check without emitting (catches resolution errors)
npx tsc --noEmit
# 2. Check for leaked alias strings in dist/
grep -r "@lib/\|@utils/\|@types/" dist/ && echo "FAIL: leaked aliases" || echo "PASS: no leaked aliases"
# 3. Check package exports are correctly wired (publint)
npx publint
# 4. Verify declaration files are resolvable by TypeScript consumers
npx attw --pack .
# 5. Smoke-test both module formats
node --input-type=module -e "import('./dist/index.mjs').then(() => console.log('ESM: PASS'))"
node -e "try { require('./dist/index.cjs'); console.log('CJS: PASS') } catch(e) { console.error('CJS: FAIL', e.message) }"
Sample publint pass output:
No issues found
Sample attw pass output:
@scope/library v1.0.0
┌─────────────────────────────────┬──────────────────────┐
│ entrypoint │ resolution │
├─────────────────────────────────┼──────────────────────┤
│ "." │ ✓ ESM, CJS │
│ "./utils" │ ✓ ESM, CJS │
└─────────────────────────────────┴──────────────────────┘
Compatibility Matrix
| Feature | Node.js 20 | Node.js 22 | TypeScript 5.0 | TypeScript 5.4+ | tsup 8+ | esbuild 0.20+ |
|---|---|---|---|---|---|---|
moduleResolution: "Node16" |
Full | Full | Full | Full | Via tsconfig | Via tsconfig |
moduleResolution: "Bundler" |
N/A (bundler only) | N/A | Full | Full | Full | Full |
Conditional exports runtime |
Full | Full | With moduleResolution: node16 |
Full | Pass-through | Pass-through |
Alias rewriting in .d.ts |
N/A | N/A | Manual (ts-patch) | Manual (ts-patch) | Built-in (dts: true) |
Not built-in |
exports.types condition |
Requires TS 4.7+ | Requires TS 4.7+ | Full | Full | Auto-generated | Not applicable |
Wildcard exports subpaths |
Node 12.22+ | Full | TS 4.7+ | Full | Full | Full |
Aliases Are a Compile-Time Fiction
The single most useful thing to internalise about paths is that TypeScript does not rewrite anything. An alias tells the type checker where to look; the emitted JavaScript keeps the specifier exactly as written. That is why aliases work perfectly through development, through tests, and through type-checking, and then fail the moment the package is installed by somebody else.
The third option is the one many library authors do not know exists. Node’s imports field is an alias mechanism the runtime understands, scoped to the package that declares it, and it works for consumers because it travels in the published package.json:
{
"imports": {
"#utils": "./dist/utils.mjs",
"#config/*": "./dist/config/*.mjs"
}
}
import { parse } from "#utils"; // resolved by Node.js, not by a build step
The # prefix is mandatory — it is what distinguishes an internal specifier from a package name. Entries are private to your package by construction, so a consumer cannot import #utils from your library, which is exactly the encapsulation you want. TypeScript understands the field under nodenext resolution, so type checking and runtime agree without a paths entry at all.
Proving No Alias Escaped
Whichever approach you choose, the check is the same and takes a second: no published file should contain an alias specifier. This is the single most valuable assertion in a package that uses aliases, because the failure it prevents is invisible in every local test.
# fail the build if any alias specifier survived into dist/
if grep -rEn 'from\s+["'"'"'](@/|~/|src/)' dist/ 2>/dev/null; then
echo "FAIL: unresolved alias in published output"
exit 1
fi
echo "OK: no alias specifiers in dist/"
Run it against declaration files too — .d.ts output is where aliases most often survive, because bundlers rewrite JavaScript specifiers but leave declarations to tsc, which does not rewrite anything. A package can therefore ship perfectly working JavaScript alongside declaration files full of @/internal/types imports that no consumer can resolve, producing the confusing report “the code works but the types are broken”.
grep -rEn 'from\s+["'"'"']@/' dist/**/*.d.ts && echo "alias leaked into declarations"
The tools that solve this properly for declarations are declaration bundlers — rollup-plugin-dts or API Extractor — which flatten the whole declaration graph into a single file with no internal specifiers left at all. That approach has a second benefit worth the setup cost: a single .d.ts is much faster for a consumer’s editor to load than a tree of hundreds of small ones, which is a real difference in a large application.
HAZARD PREVENTION
Symptom: Everything works locally and in CI, but a consumer reports
Cannot find module '@/utils'in the stack trace of your published package.Root cause:
pathswas used for runtime imports and no post-build rewrite step exists. The alias resolves through yourtsconfig.json, which is not published and would not apply to a consumer even if it were.Fix: Either bundle the package so no relative specifiers survive, add an explicit rewrite step (
tsc-aliasor an equivalent) after emit, or move the aliases to theimportsfield so Node.js resolves them at runtime. Then add thegrepassertion above so a regression fails your build rather than a consumer’s.
A last note on scope. Aliases in an application are unambiguously fine — nothing is published, and the bundler resolves everything at build time. The advice to avoid them applies specifically to packages that ship their own files to a registry, which is why an application-focused tutorial recommending paths is not wrong, merely written for a different situation than yours. The distinction to carry away is about who resolves the specifier and when: in an application the answer is always your own bundler, before anything ships, and in a published package the answer is somebody else’s toolchain, long after you have stopped looking. Any convenience that depends on configuration you do not ship is therefore a convenience you can only safely use before the emit step, never after it. Everything that survives into dist/ has to stand on its own, resolvable by a stranger’s toolchain that has no knowledge whatsoever of your repository layout or its conventions.
Three Alias Mechanisms, Only One of Which Ships
The word “alias” covers three unrelated mechanisms, and confusing them is what produces packages that work locally and fail on install.
The right-hand column is the only one that needs no build-time cooperation, which is why it is the default recommendation for a package that publishes its files individually rather than as a bundle.
Pages in This Section
- Handling TypeScript Path Aliases in Published Packages — how to prevent
@/and~aliases from leaking into publisheddist/output and how to replace them withpackage.jsonsubpath exports. - moduleResolution: bundler vs nodenext — how the two modern resolution modes differ on extension rules and
exportssupport, and which one to pick for a published library versus an application.
Related
- Optimizing tsconfig.json for Library Distribution — baseline compiler settings that complement the alias architecture described here, including
strict,skipLibCheck, and composite build options. - Declaration File Generation and Type Stripping — covers how alias paths must be rewritten consistently in
.d.tsoutput so consumer IDE navigation continues to work after building. - Mastering the package.json
exportsField — the production-grade replacement forpathsin distributed packages: conditional exports, subpath patterns, and ordering rules. - Navigating the Dual-Package Hazard — explains why shipping both ESM and CJS formats without a correct
exportsmap causes singleton bifurcation and how to prevent it. - Modern Build Tools: tsup, Rollup, and esbuild — compares bundler options for dual-format output and alias rewriting, with configuration examples for each.