tsconfig-paths vs module-alias vs pathsify: TypeScript Path Aliases (2026)
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.jsonpaths 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/dbin 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
| Feature | tsconfig-paths | module-alias | tsc-alias |
|---|---|---|---|
| When it runs | Runtime | Runtime | Compile time |
| Config source | tsconfig.json | package.json | tsconfig.json |
| ESM support | ⚠️ Partial | ⚠️ Partial | ✅ |
| Production overhead | Tiny (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.aliasin 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.