TypeScript Module Resolution — Complete Guide
In this tutorial, you will learn about TypeScript Module Resolution. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript module resolution is the algorithm that determines how an import path maps to a file on disk — understanding it is essential for configuring path aliases, resolving third-party types, and debugging mysterious "cannot find module" errors.
What You'll Learn
- Classic vs Node module resolution
- baseUrl and paths configuration
- The
exportsandimportspackage.json fields - Resolution for Node.js ESM
- Debugging with traceResolution
Why It Matters
"Module not found" errors are among the most frustrating. Understanding module resolution means you can fix them immediately, configure custom path aliases correctly, and structure your packages for maximum compatibility.
Real-World Use
DodaTech's monorepo uses paths with @ prefix for internal imports across 50+ packages. New developers often struggle with imports until they understand how baseUrl and paths interact. The exports field in package.json controls which files are publicly accessible.
Learning Path
flowchart LR A[Project References] --> B[Module Resolution] B --> C[Source Maps] B --> D[You Are Here] C --> E[Linting] E --> F[Bundling]
Classic Resolution (Legacy)
The simplest strategy — TypeScript appends file extensions and looks in relative directories:
import { foo } from "./bar";
// Checks: ./bar.ts, ./bar.tsx, ./bar.d.ts, ./bar/index.ts, etc.
Avoid classic resolution. It does not handle node_modules lookups well.
Node Resolution
Mimics Node.js's require() resolution:
import { something } from "lodash";
// 1. Check node_modules/lodash (look at package.json main/types fields)
// 2. Walk up directories checking each node_modules
// 3. Error if not found
import { utils } from "./utils";
// 1. Check ./utils.ts, ./utils.tsx, ./utils.d.ts
// 2. Check ./utils/index.ts, ./utils/index.d.ts
// 3. Error if not found
Package.json Fields
TypeScript uses these fields in order of priority:
{
"types": "dist/index.d.ts",
"typings": "dist/index.d.ts",
"main": "dist/index.js"
}
Modern packages use exports (see below).
Bundler Resolution
Introduced in TypeScript 5.0, optimized for bundlers:
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}
It relaxes some Node resolution rules:
- No require/import extension restrictions
- Allows
indeximport without./prefix in some cases - Supports package.json
importsandexports
Use this if you use Vite, Webpack, Esbuild, or tsup.
baseUrl and paths
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@utils/*": ["src/utils/*"],
"@models/*": ["src/models/*"]
}
}
}
Now imports like import { User } from "@/models/user" resolve to ./src/models/user.ts.
How Paths Work
baseUrl sets the base directory for non-relative imports. paths defines pattern-based remapping relative to baseUrl.
import { User } from "@/models/user";
// baseUrl = "."
// @/* maps to src/*
// Resolves to: ./src/models/user.ts
The exports Field (Package.json)
Modern packages use the exports field to control API surface:
{
"name": "my-lib",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js"
}
}
}
Now consumers can import:
import { mainFn } from "my-lib";
import { helperFn } from "my-lib/utils";
Subpath imports (like my-lib/utils) are not accessible unless explicitly listed in exports.
The imports Field
Map package-internal imports to avoid deep relative paths:
{
"imports": {
"#utils": "./src/utils/index.ts",
"#models/*": "./src/models/*.ts"
}
}
Internal files use:
import { helper } from "#utils";
import { User } from "#models/user";
Node16/NodeNext Resolution
For Node.js projects using ESM:
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16"
}
}
Key rules:
.jsextensions required in relative imports (even for.tsfiles)- Respects
type: "module"andtype: "commonjs"in package.json - Mixed CJS/ESM packages supported
// With Node16 resolution
import { foo } from "./bar.js"; // .js extension required
Debugging Resolution
Enable traceResolution: true to see TypeScript's resolution Process:
{
"compilerOptions": {
"traceResolution": true
}
}
npx tsc 2>&1 | head -50
Output shows every step TypeScript takes to resolve each import:
======== Resolving module './utils' from 'src/app.ts' ========
Explicitly specified module resolution kind: 'Node10'
Resolving in primary path: /Users/user/project/src/utils.ts
Resolving in primary path: /Users/user/project/src/utils.tsx
...
======== Module name 'lodash' successfully resolved to 'node_modules/lodash/index.d.ts'
Common Mistakes
1. Missing baseUrl with paths
{
// Forgot baseUrl
"paths": { "@/*": ["src/*"] } // Error: 'paths' requires 'baseUrl' or 'rootDir'
}
2. Using Classic Resolution Instead of Node/Bundler
{
"moduleResolution": "classic" // Deprecated, avoid
}
3. Not Using .js Extension with Native ESM
import { foo } from "./bar"; // Error with Node16 resolution
import { foo } from "./bar.js"; // Correct
4. Path Patterns Not Matching Glob Syntax
paths supports * as a single-segment wildcard. Use @/* mapping to ["src/*"] for multi-segment.
5. Not Exporting Subpaths in Libraries
Without explicit exports, internal paths may change without notice. Always control public API via exports.
Practice Questions
What is the difference between
nodeandbundlerresolution?bundlerrelaxes several Node resolution requirements (e.g., no extension restrictions) and supports theexportsandimportsfields.How do
baseUrlandpathswork together?baseUrlsets the root for non-relative imports.pathsprovides pattern-based remappings relative to baseUrl.What does
traceResolution: truedo? Logs every step TypeScript takes to resolve module imports, useful for debugging "cannot find module" errors.Why does Node16 resolution require
.jsextensions? It mirrors Node.js's native ESM resolution, which requires explicit file extensions.
Challenge: Configure a project with @ path aliases for src/components, src/utils, and src/types. Use bundler module resolution and verify imports work.
FAQ
Mini Project: Path Aliases Setup
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
},
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true
}
}
// src/utils/format.ts
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// src/app.ts
import { capitalize } from "@utils/format";
import { Button } from "@components/Button";
console.log(capitalize("hello")); // Hello
What's Next
Now explore source maps and debugging:
| Lesson | Description |
|---|---|
| {{< ref "/programming-languages/typescript/32-project-references" >}} | Review project references |
| {{< ref "/programming-languages/typescript/34-source-maps-debugging" >}} | Debugging with inline source maps |
| {{< ref "/programming-languages/typescript/35-linting-prettier" >}} | ESLint and Prettier setup |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro