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. import ... from "@lib/core" TypeScript (compile-time) Node.js (runtime) 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

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.

Where an alias survives and where it fails A source import written with an at-slash alias passes the type checker, is emitted verbatim into both the JavaScript and the declaration file, and then fails in the consumer's environment where no such alias is configured. The alias is never rewritten — it is only understood source from "@/utils" type check passes — paths resolves it emit "@/utils" verbatim consumer MODULE_NOT_FOUND three ways to make it safe bundle the package so no specifier survives · rewrite specifiers after emit · or use the imports field, which Node.js itself resolves at runtime

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: paths was used for runtime imports and no post-build rewrite step exists. The alias resolves through your tsconfig.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-alias or an equivalent) after emit, or move the aliases to the imports field so Node.js resolves them at runtime. Then add the grep assertion 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.

Three alias mechanisms compared tsconfig paths are resolved by the type checker only and never ship. Bundler aliases are resolved during the build and disappear from the output. The Node imports field is resolved by the runtime and is the only mechanism that travels with the published package. Who resolves it, and does it survive publishing? tsconfig paths resolved by: the type checker emitted output: unchanged ships: no — tsconfig is not published, and would not apply safe only with a rewrite step bundler alias resolved by: the bundler emitted output: inlined away ships: nothing left to ship if the package is bundled safe for bundled packages imports field resolved by: Node.js itself emitted output: unchanged ships: yes — it lives in the published package.json safe for unbundled packages

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


TypeScript Configuration & Build Tooling