Skip to main content

Guide

tsconfig-paths vs module-alias vs pathsify 2026

Compare tsconfig-paths, module-alias, and pathsify for resolving TypeScript path aliases at runtime. Fix 'Cannot find module @/' errors, tsconfig paths in.

·PkgPulse Team·
0

TL;DR

TypeScript path aliases let you write import { db } from "@/lib/db" instead of import { db } from "../../../../lib/db". TypeScript's compiler understands these paths via tsconfig.json paths config — but the runtime doesn't. After compilation, Node.js still sees @/lib/db and crashes. tsconfig-paths hooks into Node's module resolution at startup to fix this. module-alias is a older alternative using package.json aliases. In 2026: for most projects, just use a bundler (tsx, esbuild, Vite, Next.js) which handles path aliases automatically — tsconfig-paths for production Node.js servers.

Key Takeaways

  • tsconfig-paths: ~8M weekly downloads — reads tsconfig.json paths at runtime, register before app code
  • module-alias: ~2M weekly downloads — maps aliases in package.json _moduleAliases, no tsconfig dependency
  • pathsify: minimal downloads — ESM-native tsconfig paths resolution
  • The problem: TypeScript compiles @/lib/db@/lib/db in JS (doesn't rewrite paths)
  • Best solution for most projects: use tsx, Vite, esbuild, or Next.js — they handle this transparently
  • For production Node.js servers: tsconfig-paths or tsc-alias (rewrites paths at compile time)

The Problem

// tsconfig.json:
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@lib/*": ["src/lib/*"],
      "@types/*": ["src/types/*"]
    }
  }
}

// Your TypeScript:
import { db } from "@/lib/database"      // ✅ TypeScript understands this
import { Package } from "@types/package"  // ✅

// After tsc compilation (dist/index.js):
const database_1 = require("@/lib/database")  // ❌ Node.js: "Cannot find module '@/lib/database'"

// The path alias was NOT rewritten — Node.js has no idea what @/ means

Solutions Overview

Option 1: tsconfig-paths       — runtime hook, reads paths from tsconfig at startup
Option 2: module-alias         — runtime hook, reads aliases from package.json
Option 3: tsc-alias            — compile-time, rewrites paths in compiled JS files
Option 4: bundler (esbuild/Vite) — handles paths during bundling (no runtime cost)
Option 5: tsx / ts-node        — transpiles on-the-fly, handles paths automatically

tsconfig-paths

tsconfig-paths — runtime path resolution:

Setup

npm install tsconfig-paths

Node.js usage

# Register before your app starts:
node -r tsconfig-paths/register dist/index.js

# Or in package.json:
{
  "scripts": {
    "start": "node -r tsconfig-paths/register dist/index.js",
    "dev": "tsx src/index.ts"  # tsx handles paths automatically
  }
}

Programmatic registration

// src/register.ts — import this FIRST in your entry point:
import { register } from "tsconfig-paths"
import { resolve } from "node:path"

// Register paths from tsconfig.json:
register({
  baseUrl: resolve(__dirname, ".."),  // Adjust to your project root
  paths: {
    "@/*": ["src/*"],
    "@lib/*": ["src/lib/*"],
  },
})

// Now @/ imports work in subsequent requires/imports
// src/index.ts:
import "./register"  // Must be FIRST import
import { db } from "@/lib/database"  // Now works at runtime
import { PackageService } from "@lib/services/package"

With ts-node

# ts-node with tsconfig-paths:
ts-node -r tsconfig-paths/register src/index.ts

# Or via tsconfig.json (ts-node section):
# {
#   "ts-node": {
#     "require": ["tsconfig-paths/register"]
#   }
# }

GitHub Actions / Docker

# Dockerfile:
CMD ["node", "-r", "tsconfig-paths/register", "dist/index.js"]
# GitHub Actions:
- name: Start server
  run: node -r tsconfig-paths/register dist/index.js &

Full tsconfig.json example

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@lib/*": ["src/lib/*"],
      "@services/*": ["src/services/*"],
      "@models/*": ["src/models/*"],
      "@utils/*": ["src/utils/*"],
      "@config": ["src/config/index.ts"]
    },
    "strict": true,
    "esModuleInterop": true
  }
}

module-alias

module-alias — package.json-based aliases:

Setup

npm install module-alias

Configuration (package.json)

{
  "name": "pkgpulse-api",
  "_moduleAliases": {
    "@": "./dist",
    "@lib": "./dist/lib",
    "@services": "./dist/services",
    "@models": "./dist/models",
    "@utils": "./dist/utils",
    "@config": "./dist/config/index.js"
  }
}
// src/index.ts — register at the top:
import "module-alias/register"

// Now aliases work:
import { db } from "@lib/database"
import { PackageService } from "@services/package"

Key differences from tsconfig-paths

tsconfig-paths:
  - Reads paths directly from tsconfig.json
  - Paths are relative to source (src/) — matches TypeScript resolution
  - Must point to dist/ in production (or adjust baseUrl)

module-alias:
  - Reads from package.json _moduleAliases
  - Must point to compiled dist/ files (production-ready)
  - Separate config from tsconfig — can get out of sync
  - More explicit about runtime vs compile-time paths

tsc-alias (compile-time alternative)

tsc-alias — rewrites paths in compiled output:

npm install -D tsc-alias
// package.json:
{
  "scripts": {
    "build": "tsc && tsc-alias",
    "start": "node dist/index.js"   // No -r flag needed!
  }
}
tsc:       src/index.ts → dist/index.js (paths NOT rewritten)
tsc-alias: dist/index.js → dist/index.js (paths rewritten to relative)

Result in dist/index.js:
  Before: const database = require("@/lib/database")
  After:  const database = require("./lib/database")  ✅ Node.js works natively

The Better Alternative: Just Use a Bundler/Transpiler

For most projects, you don't need tsconfig-paths or module-alias at all:

tsx / ts-node-esm:
  → transpiles TypeScript on-the-fly, handles paths from tsconfig automatically
  → use for scripts, CLI tools, development

Vite (+ SvelteKit, Astro):
  → configure resolve.alias in vite.config.ts
  → built-in path alias support, no extra package needed

Next.js:
  → automatically handles tsconfig paths
  → just configure tsconfig.json, Next.js handles the rest

esbuild (via tsup/tsdown):
  → configure tsconfigRaw.compilerOptions.paths
  → bundled output has no path aliases at all (inlined)

Vite path aliases

// vite.config.ts:
import { defineConfig } from "vite"
import { resolve } from "node:path"

export default defineConfig({
  resolve: {
    alias: {
      "@": resolve(__dirname, "./src"),
      "@lib": resolve(__dirname, "./src/lib"),
      "@components": resolve(__dirname, "./src/components"),
    },
  },
})

// tsconfig.json (for TypeScript type checking):
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@lib/*": ["./src/lib/*"]
    }
  }
}

Next.js (automatic)

// tsconfig.json — Next.js reads and handles this automatically:
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
// No tsconfig-paths, no module-alias needed — Next.js handles it

Feature Comparison

Featuretsconfig-pathsmodule-aliastsc-alias
When it runsRuntimeRuntimeCompile time
Config sourcetsconfig.jsonpackage.jsontsconfig.json
ESM support⚠️ Partial⚠️ Partial
Production overheadTiny (startup only)Tiny (startup only)None
Keeps paths in sync✅ (reads tsconfig)❌ (manual sync)✅ (reads tsconfig)
Works without bundler
Weekly downloads~8M~2M~500K

When to Use Each

Use tsconfig-paths if:

  • Production Node.js server compiled with tsc (Express, Fastify, NestJS)
  • Want paths to stay in sync with tsconfig.json automatically
  • CommonJS output ("module": "commonjs" in tsconfig)

Use module-alias if:

  • Need explicit control over runtime vs compile-time paths
  • Monorepo where paths differ between packages
  • Non-TypeScript projects that want path aliases

Use tsc-alias if:

  • Want zero runtime overhead — paths rewritten at compile time
  • ESM output ("module": "es2022" or "nodenext")
  • CI/CD where you want no runtime dependencies

Don't need any of these if:

  • Using Next.js, Nuxt, SvelteKit — framework handles paths
  • Using Vite — configure resolve.alias in vite.config.ts
  • Using tsx for scripts — reads tsconfig automatically
  • Using tsup/tsdown — bundled output has no aliases

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on tsconfig-paths v4.x, module-alias v2.x, and tsc-alias v1.x.

ESM vs CJS: The Compatibility Cliff

The biggest gotcha with tsconfig-paths and module-alias in 2026 is ESM compatibility. Both libraries work reliably for CommonJS ("module": "commonjs" in tsconfig) but have significant limitations with native ESM output. Node.js ESM loader hooks use a different API than the legacy require hooks these libraries patch, and the upgrade path is not straightforward.

tsconfig-paths works by patching Node.js's require function via the -r flag (require-time hooks). This approach is fundamentally incompatible with native ESM modules, which use import instead of require and have a separate loader API. If your tsconfig targets "module": "nodenext" or "module": "es2022" with .js extension imports, tsconfig-paths will not intercept ESM import statements and the paths will remain unresolved.

For ESM projects, the correct solutions are either tsc-alias (compile-time path rewriting, fully ESM-compatible) or a bundler. tsc-alias runs after tsc and rewrites all path aliases in the compiled output to relative paths that Node.js's ESM resolver can handle natively. The result is a dist/ directory with no path aliases at all — just relative imports that work without any runtime hook. This is the recommended approach for production Node.js ESM servers in 2026.

module-alias has the same limitation and is additionally hampered by being largely unmaintained — the package hasn't had significant updates in years. The _moduleAliases convention it introduced never became a standard, meaning it doesn't interoperate with TypeScript's language server for type resolution, requiring you to maintain the paths configuration in both tsconfig.json and package.json separately.

Path Aliases in Monorepos

Path aliases in monorepo workspaces introduce additional complexity that tsconfig-paths handles better than module-alias in practice. A typical monorepo has a root tsconfig.base.json with shared paths that individual packages extend. tsconfig-paths automatically resolves which tsconfig to use based on the entry point being executed, reading the extends chain correctly.

The common monorepo pattern is defining shared paths at the root and having each package's tsconfig extend them:

The runtime challenge in monorepos is that tsconfig-paths/register reads the tsconfig relative to where Node.js is invoked, not relative to the source file. If you run node -r tsconfig-paths/register dist/apps/api/index.js from the monorepo root, tsconfig-paths needs to find the right tsconfig for that package. The programmatic registration API (register({ baseUrl, paths })) with explicit configuration is more reliable in monorepos than the automatic discovery mode.

For monorepos using pnpm workspaces or Turborepo, tsc-alias's --resolveFullPaths option handles a subtlety that affects monorepos: when a package in packages/shared exports types that other packages import via path alias, the compiled output needs the alias resolved in the context of each consuming package's output directory, not the shared package's directory. tsc-alias handles this correctly; tsconfig-paths's runtime approach doesn't have this problem but requires all packages to register their own paths before use.

The cleanest monorepo TypeScript setup in 2026 is: tsx for development (handles path resolution automatically from tsconfig), tsc + tsc-alias for production builds (generates portable output with no runtime dependencies), and Next.js/Vite handling their own path resolution for frontend packages via their native config.

Compare TypeScript tooling and build packages on PkgPulse →

Debugging Path Resolution Failures

When path alias resolution breaks, the error message — Cannot find module '@/lib/db' or its corresponding type declarations — is clear enough, but identifying the root cause requires checking several layers. The first question is whether the failure happens at compile time (TypeScript type checking, tsc --noEmit) or at runtime (Node.js execution). These are independent problems with different fixes.

A compile-time failure means your tsconfig.json paths configuration doesn't match the import. Check that baseUrl is set (required for paths to work) and that the glob pattern matches: @/* covers @/anything/deep, but @/lib/* does not cover @/lib/db/connection unless you also add @/lib/**/*. The TypeScript language server in VS Code reads the nearest tsconfig.json to the file being edited — in a monorepo, a tsconfig.json at the package level that doesn't include the correct baseUrl will cause editor errors even if the root tsconfig.base.json has the right configuration.

A runtime failure with tsconfig-paths registered usually means the baseUrl in the registered configuration doesn't align with the actual file locations at runtime. tsconfig-paths reads baseUrl relative to the tsconfig.json location, but after tsc compilation, files are in dist/ while the tsconfig remains at the root. The correct production setup is either using the programmatic register({ baseUrl: path.resolve(__dirname, '..') }) with a hardcoded production path, or switching to tsc-alias which rewrites paths at compile time and eliminates the runtime alignment problem entirely.

A useful debugging step: set process.env.DEBUG = 'tsconfig-paths*' before starting your Node.js process. tsconfig-paths emits debug output showing each path alias lookup, which alias matched, and which file was ultimately loaded. This makes it straightforward to see whether the registration is happening before the import, whether the pattern is matching, and whether the resolved file path is correct.

When to Use Each

TypeScript path aliases (@/utils, ~lib) are a developer experience feature that requires two layers: TypeScript compilation (type checking) and runtime resolution (Node.js execution). These packages solve the runtime layer.

Use tsconfig-paths if:

  • You run your TypeScript directly with ts-node or tsx
  • You want tsconfig.paths to work at runtime without additional config
  • You need the most widely-used and actively maintained solution
  • You are adding path alias support to an existing ts-node workflow

Use module-alias if:

  • You compile TypeScript to JavaScript first and run the compiled output
  • You need path aliases to work in the compiled dist/ folder
  • You are working in a pure Node.js project without TypeScript

Use pathsify if:

  • You are using the esbuild bundler and need a plugin to resolve TypeScript paths
  • You want a lightweight esbuild-native solution rather than a runtime patch

In 2026, the most common recommendation is to configure path aliases at the bundler level (Vite/esbuild/webpack) rather than patching Node.js module resolution at runtime. tsup (which uses esbuild internally) handles path aliases when paths is set in tsconfig.json. This makes tsconfig-paths and module-alias most relevant in ts-node or Node.js scripts that run outside a bundler.

The cleanest long-term solution in 2026 is to eliminate runtime path alias patches entirely by using a bundler that resolves aliases at build time. If you use tsup, esbuild, or Vite, configure paths in tsconfig.json and the bundler handles resolution — no runtime patching needed.

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.