TypeScript Module Resolution: How TypeScript Finds Modules
When you write `import { Button } from '@/components/Button'` or `import express from 'express'`, TypeScript needs to translate these import paths into actual file locations on your filesystem. This...
Key Insights
- TypeScript uses two resolution strategies—Classic (legacy) and Node (default)—with Node resolution mimicking Node.js’s algorithm by traversing up the directory tree looking for
node_modulesfolders - Path mapping with
baseUrlandpathsin tsconfig.json allows you to create clean import aliases, but these are compile-time only and require runtime configuration for bundlers or Node.js - Use
tsc --traceResolutionto debug module resolution issues—it shows the exact steps TypeScript takes to locate each module, revealing why imports fail or resolve to unexpected files
Introduction to Module Resolution
When you write import { Button } from '@/components/Button' or import express from 'express', TypeScript needs to translate these import paths into actual file locations on your filesystem. This process is called module resolution, and understanding it is critical for debugging import errors, configuring monorepos, and optimizing your build setup.
Module resolution determines how TypeScript maps import specifiers to files. Get it wrong, and you’ll face cryptic “Cannot find module” errors even when the file clearly exists. Get it right, and you can create clean, maintainable import paths that make your codebase easier to navigate.
// How does TypeScript find these files?
import { User } from './models/User'; // Relative import
import { validate } from 'validator'; // Package import
import { Button } from '@/components/Button'; // Path-mapped import
The complexity arises because TypeScript must handle multiple scenarios: relative imports, node_modules packages, type definitions, path aliases, and compatibility with different module systems. Let’s break down exactly how this works.
Module Resolution Strategies
TypeScript supports two module resolution strategies: Classic and Node. You configure this in tsconfig.json with the moduleResolution option.
Classic resolution is the legacy strategy, primarily used when module is set to AMD, System, or ES2015. It’s rarely used in modern projects but understanding it helps clarify why Node resolution exists.
Node resolution mimics Node.js’s CommonJS resolution algorithm and is the default for most projects. It’s what you want for any project using npm packages.
{
"compilerOptions": {
"moduleResolution": "node", // or "node16", "nodenext", "bundler"
"module": "commonjs"
}
}
Here’s how the same import resolves differently:
// Given: import { helper } from 'utils'
// From file: /src/app/main.ts
// Classic resolution:
// 1. /src/app/utils.ts
// 2. /src/app/utils.d.ts
// 3. /src/utils.ts
// 4. /src/utils.d.ts
// 5. /utils.ts
// 6. /utils.d.ts
// Node resolution:
// 1. /src/app/node_modules/utils.ts
// 2. /src/app/node_modules/utils.tsx
// 3. /src/app/node_modules/utils.d.ts
// 4. /src/app/node_modules/utils/package.json (check "types" field)
// 5. /src/app/node_modules/utils/index.ts
// 6. /src/app/node_modules/@types/utils/index.d.ts
// 7. /src/node_modules/utils... (repeat)
// 8. /node_modules/utils... (repeat)
Classic resolution walks up directories but never checks node_modules. Node resolution checks node_modules at every level, making it suitable for npm-based projects.
Node Module Resolution Deep Dive
Node resolution handles two types of imports differently: relative and non-relative.
Relative imports start with ./, ../, or /. TypeScript resolves these directly from the importing file’s location:
// In /src/services/auth.ts
import { User } from '../models/User';
// Resolution order:
// 1. /src/models/User.ts
// 2. /src/models/User.tsx
// 3. /src/models/User.d.ts
// 4. /src/models/User/package.json (check "types")
// 5. /src/models/User/index.ts
// 6. /src/models/User/index.tsx
// 7. /src/models/User/index.d.ts
Non-relative imports don’t start with a relative path indicator. TypeScript treats these as package imports and searches node_modules folders:
// In /src/services/auth.ts
import jwt from 'jsonwebtoken';
// Resolution starts at /src/services/node_modules and walks up:
// /src/services/node_modules/jsonwebtoken
// /src/node_modules/jsonwebtoken
// /node_modules/jsonwebtoken
For each node_modules/package location, TypeScript checks the package’s package.json for type information:
{
"name": "my-library",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
TypeScript checks these fields in order:
exports(if usingmoduleResolution: "node16"or"nodenext")typesortypingsmain(assumes corresponding.d.tsfile exists)
If no type definitions exist in the package, TypeScript looks for @types/package-name in node_modules/@types.
Here’s a real-world trace:
/project
├── node_modules
│ ├── express
│ │ ├── package.json (main: "index.js", types: undefined)
│ │ └── index.js
│ └── @types
│ └── express
│ ├── package.json (types: "index.d.ts")
│ └── index.d.ts
└── src
└── server.ts
// In /project/src/server.ts
import express from 'express';
// Resolution:
// 1. /project/src/node_modules/express - not found
// 2. /project/node_modules/express - found!
// 3. Check /project/node_modules/express/package.json - no "types" field
// 4. Check /project/node_modules/@types/express - found!
// 5. Resolved to: /project/node_modules/@types/express/index.d.ts
Path Mapping and BaseUrl
Path mapping lets you create clean import aliases instead of relative path hell:
// Without path mapping
import { Button } from '../../../components/ui/Button';
import { formatDate } from '../../../utils/date';
// With path mapping
import { Button } from '@/components/ui/Button';
import { formatDate } from '@/utils/date';
Configure this in tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"],
"~/*": ["./"]
}
}
}
baseUrl sets the root for non-relative module names. paths defines mapping patterns. The * is a wildcard that matches any substring.
Critical gotcha: TypeScript only uses these mappings for type checking and compilation. Your bundler (Webpack, Vite, esbuild) or runtime (Node.js) needs separate configuration to resolve these paths. For Node.js, use tsconfig-paths or set up import maps.
// For Node.js runtime (register before app code)
require('tsconfig-paths/register');
// Or in package.json with ts-node
{
"ts-node": {
"require": ["tsconfig-paths/register"]
}
}
You can also use rootDirs to treat multiple directories as one virtual directory:
{
"compilerOptions": {
"rootDirs": ["src", "generated"]
}
}
This allows imports to work as if src and generated were merged, useful for build-time code generation.
Common Resolution Issues and Debugging
The --traceResolution flag is your best debugging tool. It outputs TypeScript’s entire resolution process:
tsc --traceResolution | grep "my-module"
======== Resolving module 'my-module' from '/src/app.ts'. ========
Module resolution kind is not specified, using 'NodeJs'.
Loading module 'my-module' from 'node_modules' folder, target file type 'TypeScript'.
Directory '/src/node_modules' does not exist, skipping all lookups in it.
Directory '/node_modules' does not exist, skipping all lookups in it.
======== Module name 'my-module' was not resolved. ========
Common issues and fixes:
Problem: “Cannot find module ‘X’ or its corresponding type declarations”
import axios from 'axios'; // Error!
Fix: Install type definitions:
npm install --save-dev @types/axios
Or if types don’t exist, declare the module:
// types/global.d.ts
declare module 'axios';
Problem: Path mapping doesn’t work at runtime
Fix: Configure your bundler or use tsconfig-paths for Node.js.
Problem: Importing from node_modules package subdirectories fails
import { Button } from 'my-ui-lib/components/Button'; // Error!
Fix: Check if the package exports this path. Many packages only export the main entry point. You may need to import from the root and destructure.
Problem: Mixing ESM and CommonJS
With moduleResolution: "node16" or "nodenext", TypeScript enforces strict ESM/CJS rules. Files with .mts or "type": "module" in package.json must use ESM syntax.
Best Practices
1. Use Node resolution for all modern projects
{
"compilerOptions": {
"moduleResolution": "bundler", // or "nodenext" for Node.js
"module": "ESNext",
"target": "ES2020"
}
}
The "bundler" option (TypeScript 5.0+) is ideal for apps using Webpack, Vite, or esbuild. Use "nodenext" for Node.js libraries.
2. Keep path mappings simple
Only create aliases for frequently-used directories:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Avoid creating too many aliases—they make the codebase harder for newcomers to understand.
3. For monorepos, use project references
{
"compilerOptions": {
"composite": true,
"declaration": true
},
"references": [
{ "path": "../shared" },
{ "path": "../utils" }
]
}
This enables incremental builds and proper type checking across packages.
4. Include file extensions in relative imports for ESM compatibility
// Good for ESM
import { helper } from './utils.js';
// Works in TypeScript but breaks in ESM
import { helper } from './utils';
TypeScript will resolve ./utils.js to ./utils.ts during compilation, but the output will have the correct .js extension for ESM.
5. Document your path mappings
Add a comment in tsconfig.json explaining your alias strategy:
{
"compilerOptions": {
"paths": {
// @/* maps to src/* for cleaner imports throughout the app
"@/*": ["src/*"]
}
}
}
Understanding module resolution transforms TypeScript from a mysterious black box into a predictable tool. When imports fail, you’ll know exactly where to look and how to fix them. Configure your resolution strategy deliberately, use path mapping judiciously, and leverage --traceResolution when things go wrong.