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 vs Node.js Module Resolution Pipelines Two parallel swimlanes showing how import @lib/core is handled. The TypeScript lane resolves via tsconfig paths to ./src/lib/core.ts for type checking. The Node.js runtime lane ignores tsconfig entirely and resolves via package.json exports or node_modules, failing on raw alias strings. TypeScript (compile-time) Node.js (runtime) import ... from "@lib/core" Read tsconfig.json paths "@lib/*" → "./src/lib/*" Resolve → ./src/lib/core.ts Type-checks pass tsconfig.json ignored checks package.json exports No mapping for "@lib/core" ERR_MODULE_NOT_FOUND Fix: build-time alias rewriting + package.json exports mapping

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 .js extensions and unexported subpaths, causing ERR_MODULE_NOT_FOUND at runtime. Use moduleResolution: "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 against src/ before node_modules, meaning import { clone } from "lodash" fails if src/lodash.ts exists. 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: true and verify that alias transformations do not strip import type statements prematurely. Use tsc --declarationMap alongside 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 from exports. With moduleResolution: "node16", TypeScript will not locate declarations if "types" is absent from the export map, causing Cannot 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_modules resolution failures. Always run pnpm install --frozen-lockfile or npm ci in validation jobs to guarantee a pristine dependency graph that reflects what consumers will actually install.

Hazard Call-Outs

HAZARD PREVENTION: Publishing packages with unresolved paths aliases — TypeScript emits raw alias strings (@lib/core) in .js output without runtime transformation, causing ERR_MODULE_NOT_FOUND. Fix: run grep -r "@lib/" dist/ as a pre-publish check; add build-time path rewriting via tsup’s esbuildOptions.alias or rollup-plugin-alias.

HAZARD PREVENTION: Mismatched moduleResolution between tsconfig and bundler — the compiler accepts missing .js extensions and unexported paths under "bundler" mode, while the Node.js runtime requires both. Fix: set "moduleResolution": "Node16" in tsconfig.json and configure your bundler’s resolution fields to match Node’s exports-first lookup.

HAZARD PREVENTION: ERR_PACKAGE_PATH_NOT_EXPORTED from legacy alias resolution — occurs when consumers attempt to import subpaths not listed in exports, often because an old @lib/core alias was not mapped to a corresponding exports entry. Fix: audit every public alias and add a matching exports key; run publint to surface unmapped subpaths before publishing.

HAZARD PREVENTION: ESM runtime fails to resolve .js extensions from .ts sources — Node ESM requires explicit extensions; TypeScript paths does not auto-append them. Fix: write source imports as import { x } from "./lib/core.js" (not .ts) when targeting native ESM, and configure exports conditions 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


TypeScript Configuration & Build Tooling