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 |
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.