Module Resolution and Path Aliases
Introduction
When you write import { x } from "./utils", TypeScript must resolve that specifier to a file or package. Module resolution rules depend on moduleResolution in tsconfig.json. Path aliases shorten long relative paths (@/components/Button). This chapter explains how resolution works and how to configure aliases safely.
Prerequisites
- Modules in TypeScript
- A project with
tsconfig.json
moduleResolution Overview
| Setting | Typical use |
|---|---|
Node10 / Node | Older Node resolution (legacy) |
Node16 / NodeNext | Modern Node ESM + package.json "exports" |
Bundler | Vite, Webpack, esbuild—matches bundler behavior |
Modern Node projects often use:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}module and moduleResolution should align—check TypeScript handbook when upgrading.
Relative vs Bare Specifiers
import { add } from "./math.js";
import express from "express";Code explanation:
./math.jsis relative—resolved from the importing file’s directoryexpressis bare—resolved fromnode_modulesusing Node or bundler rules
package.json "type" and "exports"
Node ESM vs CJS depends on package metadata:
{
"name": "my-lib",
"type": "module",
"exports": {
".": "./dist/index.js"
}
}TypeScript reads these fields with NodeNext to decide which .d.ts and .js files match an import.
baseUrl and paths (Path Aliases)
Map logical prefixes to folders:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}Usage:
import { formatUser } from "@/models/user.js";Code explanation:
@/*maps tosrc/*for the type checker- Runtime/bundler must apply the same mapping (see below)
Warning
paths Alone Does Not Rewrite Runtime
tsc does not rewrite alias paths in emitted JS unless you use a tool that does. Bundlers (Vite) or tsc-alias / Node subpath imports must match.
Vite example (conceptual)
vite.config.ts often mirrors:
import path from "node:path";
export default {
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
};TypeScript paths and Vite alias stay in sync.
rootDirs (Monorepo Hint)
Multiple roots treated as one logical tree:
{
"compilerOptions": {
"rootDirs": ["src", "generated"]
}
}Used when generated and hand-written code merge in imports—advanced monorepo setups.
types and typeRoots
{
"compilerOptions": {
"types": ["node"]
}
}Loads @types/node globally. Omit types to include all visible @types packages.
Troubleshooting Resolution Errors
| Error | Common cause | Fix |
|---|---|---|
Cannot find module './x' | Wrong path or missing extension (NodeNext) | Fix relative path; add .js |
Cannot find module 'express' | Package not installed | npm install express + @types/express if needed |
Cannot find module '@/foo' | paths not configured in tool | Add paths + bundler alias |
| Types exist but value import fails | export field in dependency | Check package exports / use correct import |
Enable trace (debugging):
npx tsc --traceResolution 2>&1 | head -50Verbose output shows candidate files—use sparingly.
Subpath Imports via package.json (Node)
Alternative to paths for libraries:
{
"imports": {
"#utils/*": "./src/utils/*"
}
}Node 20+ supports import maps in apps; align TypeScript with moduleResolution: "NodeNext" and proper typings.
Practical tsconfig Fragment
Bundler fits Vite/React apps; Node services prefer NodeNext.
FAQ
NodeNext vs Bundler?
NodeNext for tsc + node runs. Bundler when a bundler compiles and you want matching resolution.
Why .js in paths mapping?
"@/*": ["src/*"] maps to .ts sources; imports still often use .js suffix for Node ESM emit.
Do path aliases work in tests?
Jest/Vitest need their own moduleNameMapper / resolve.alias matching paths.
Can paths point outside project?
Yes, but prefer local src aliases; crossing repos needs project references.
project references?
Separate tsconfig per package with references—monorepo topic beyond this chapter.
Relative import hell?
Aliases help; also colocate files and avoid deep ../../../ when structure allows.