Path Mapping and Module Resolution Strategies
Configure TypeScript path aliases with build-time resolution, map tsconfig paths to package.json imports/exports, and prevent leaked aliases in published npm package outputs.
Effective TypeScript path mapping module resolution requires a strict separation between compile-time type checking and runtime execution environments. Misalignment between these layers is the leading cause of broken imports, silent type errors, and failed package distributions. This guide establishes production-grade patterns for alias architecture, build-time transformation, and CI validation, operating within the broader TypeScript Configuration & Build Tooling ecosystem.
Compiler vs Runtime Resolution Mechanics
TypeScript’s paths directive operates exclusively during type-checking and compilation. It instructs the compiler how to locate source files for IntelliSense and type validation, but it performs zero runtime resolution or AST rewriting. Conversely, Node.js relies on native resolution algorithms: ESM uses package.json exports/imports fields, while CommonJS (CJS) falls back to require() resolution order (node_modules, NODE_PATH, relative paths).
The moduleResolution compiler option dictates how closely TypeScript mimics these runtime behaviors. 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.
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"paths": {
"@core/*": ["./src/core/*"]
}
}
}
️ HAZARD PREVENTION: Never pair
moduleResolution: "bundler"with a library targeting native Node execution. The compiler will silently accept missing.jsextensions and unexported paths, causingERR_MODULE_NOT_FOUNDat runtime. AlignmoduleResolutionstrictly with your target runtime.
Strategic paths and baseUrl Architecture
Maintainable aliasing requires strict scoping and collision avoidance. Mapping aliases to vendor-specific namespaces (e.g., @internal/*, @pkg/*) prevents shadowing third-party dependencies. Root-level baseUrl mapping without explicit paths prefixes forces TypeScript to resolve bare imports against your source tree first, creating implicit collisions with node_modules packages.
Granularity matters: prefer directory-level wildcards ("@utils/*": ["./src/utils/*"]) over file-level mappings to reduce configuration drift. Cross-platform normalization is handled automatically by TypeScript’s POSIX-to-Windows path translation, but rootDirs can introduce ambiguity when merging output directories. Baseline compiler settings that complement these strategies are detailed in Optimizing tsconfig.json for Library Distribution.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@lib/*": ["./src/lib/*"],
"@types/*": ["./src/types/*"]
}
}
}
️ HAZARD PREVENTION: Avoid
"baseUrl": "src"with"paths": { "*": ["*"] }. This forces the compiler to resolve every bare import against your source tree first, causingimport { lodash } from "lodash"to fail if a localsrc/lodash.tsexists. Always scope aliases under a dedicated prefix.
Build-Time Alias Transformation Pipelines
Since TypeScript does not rewrite paths in emitted JavaScript, build-time AST transformation is mandatory for distribution. Regex-based string replacement is fragile and breaks sourcemaps, declaration files, and minified outputs. Use AST-aware bundler plugins (tsup, rollup-plugin-alias, esbuild) that traverse the syntax tree and rewrite import specifiers safely.
Type-only imports (import type { X } from "@lib/x") are stripped during declaration emission but must still resolve correctly during the initial compilation pass. When generating .d.ts files, the transformer must preserve or rewrite type paths consistently to avoid broken consumer references. The mechanics of this process are covered in Declaration File Generation and Type Stripping.
// tsup.config.ts
import { defineConfig } from "tsup";
import alias from "rollup-plugin-alias";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
plugins: [
alias({
entries: [
{ find: /^@lib\/(.*)/, replacement: "./src/lib/$1" },
],
}),
],
});
️ HAZARD PREVENTION: Always enable
sourcemap: trueand verify that alias transformations do not stripimport typestatements prematurely. Usetsc --declarationMapalongside your bundler to maintain IDE navigation in downstream projects.
CI/CD Resolution Validation Workflows
Automated pre-publish checks must detect resolution mismatches before artifacts reach the registry. Run isolated tsc --noEmit in clean CI runners without local caches to catch missing type references. Matrix test across Node.js LTS versions and package managers (npm, pnpm, yarn) to expose hoisting and resolution order discrepancies.
Custom AST scanners can lint for unresolved aliases by parsing the tsconfig.json paths and verifying every import matches a defined pattern. Automated smoke tests should execute both ESM and CJS entry points to validate runtime resolution chains.
# .github/workflows/resolution-validation.yml
name: Resolution Validation
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Type Check (Clean)
run: pnpm tsc --noEmit --force
- name: Smoke Test Dual Entry
run: |
node --input-type=module -e "import '@lib/core'; console.log('ESM OK')"
node -e "require('@lib/core'); console.log('CJS OK')"
️ HAZARD PREVENTION: CI caches often mask
node_modulesresolution failures. Always runpnpm install --frozen-lockfileornpm ciwith--forcein validation jobs to guarantee a pristine dependency graph.
Dual ESM/CJS Export Mapping
Shipping both module formats requires explicit conditional exports to guide runtimes and type checkers. Map .mjs outputs to import conditions and .cjs to require. ESM mandates explicit .js extensions in source imports, even when referencing .ts files. Fallback chains ("default") must be ordered carefully to prevent bundlers from selecting the wrong format.
Consumer-side type resolution depends on the types field pointing to a valid .d.ts entry point. Misaligned exports conditions cause TypeScript to fall back to legacy main/module fields, breaking modern resolution. For comprehensive strategies on bridging runtime mechanics with downstream compatibility, see Handling TypeScript Path Aliases in Published Packages.
{
"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.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}
️ HAZARD PREVENTION: Never omit the
typescondition inexports. TypeScript’smoduleResolution: "node16"will fail to locate declarations iftypesis not explicitly mapped, causingCannot find moduleerrors for consumers.
Common Pitfalls & Resolution Matrix
| Issue | Root Cause | Resolution |
|---|---|---|
Publishing packages with unresolved paths aliases |
TypeScript emits raw alias strings in .js output without runtime transformation |
Implement build-time path rewriting plugins and enforce tsc --noEmit validation in CI before publishing. |
Mismatched moduleResolution between TS config and bundler |
Compiler assumes bundler resolution while runtime expects strict Node16 rules | Align moduleResolution to node16 or nodenext and explicitly configure bundler resolution fields to match runtime behavior. |
Overusing baseUrl causing implicit import collisions |
Root-level mapping forces bare imports to resolve against source tree before node_modules |
Scope aliases under a dedicated namespace (e.g., @lib/*) and avoid mapping root directories to prevent ambiguous resolution. |
ESM runtime failing to resolve .js extensions mapped from .ts sources |
Node ESM requires explicit extensions; TS paths does not auto-append them |
Configure explicit exports conditions and enforce .js extensions in source imports when targeting native ESM execution. |