Module Resolution
Module resolution is the algorithm TypeScript uses to figure out what a string like
'./utils' or 'lodash' actually refers to on disk. Getting it wrong causes
"Cannot find module" errors even when the file exists. Getting it right is essential
for every TypeScript project.
This page covers every resolution strategy, how they differ, and how to configure them correctly for modern Node.js, bundlers, and monorepos.
The Two Phases of Resolution
When TypeScript sees import { x } from 'some-module', it runs two distinct steps:
- Locate the file — apply the resolution algorithm to find the
.ts,.tsx, or.d.tsfile that declares the module. - Type-check the import — verify that
xis actually exported by that file.
If step 1 fails, you get a "Cannot find module" error. If step 2 fails, you get a "Module has no exported member" error. They are separate problems with separate fixes.
Relative vs Non-Relative Imports
Import style | Example | Resolution basis |
|---|---|---|
Relative | './utils' | Resolved from the importing file |
Relative | '../config/index' | Resolved from the importing file |
Non-relative | 'lodash' | Resolved from node_modules |
Non-relative | '@myapp/shared' | Resolved from node_modules or paths |
Relative imports always start with ./ or ../. Everything else is non-relative
and goes through the node_modules search chain.
The moduleResolution Option
The moduleResolution compiler option controls which algorithm TypeScript uses.
The valid values are:
Value | TypeScript version | Best for |
|---|---|---|
classic | Legacy (pre-TS 1) | Avoid — deprecated |
node | TS 1+ | CommonJS Node.js (older projects) |
node16 | TS 4.7+ | Node.js ESM with .js extensions |
nodenext | TS 4.7+ | Latest Node.js ESM (mirrors node16) |
bundler | TS 5.0+ | Vite, esbuild, webpack, Parcel |
// tsconfig.json — recommended for modern bundler projects (Next.js, Vite)
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
}
}
// tsconfig.json — recommended for Node.js ESM projects
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16"
}
}
module and moduleResolution options are related but distinct. module controls the emitted JavaScript syntax. moduleResolution controls how TypeScript finds files at compile time.Node Resolution Algorithm (Classic)
The node strategy mirrors how Node.js require() works for CommonJS:
Exact file match: try './utils.ts', then './utils.tsx', then './utils.d.ts'
Directory index: try './utils/index.ts', './utils/index.tsx', './utils/index.d.ts'
For non-relative: walk up node_modules/ directories until the root
Inside node_modules/pkg/: check package.json "types" or "typings" field
Fall back to node_modules/pkg/index.d.ts
// Given: import { helper } from './utils'
// TypeScript checks in order:
// 1. ./utils.ts
// 2. ./utils.tsx
// 3. ./utils.d.ts
// 4. ./utils/package.json (if "types" field exists)
// 5. ./utils/index.ts
// 6. ./utils/index.tsx
// 7. ./utils/index.d.ts
Node16 / NodeNext Resolution
The node16 and nodenext strategies support Node.js's native ESM, which has
stricter rules: you must include file extensions in import paths, and
package.json "exports" fields are respected.
// node16 mode — you write .js even though the source is .ts
// TypeScript maps 'utils.js' to 'utils.ts' at compile time
import { helper } from './utils.js'; // ✓ in node16/nodenext mode
import { helper } from './utils'; // ✗ Error in node16/nodenext mode
// package.json "exports" field is also honoured:
// {
// "exports": {
// ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }
// }
// }
node16/nodenext mode, relative imports without extensions cause errors. Add .js extensions to all relative imports (TypeScript resolves them to .ts files automatically).Bundler Resolution
The bundler strategy (TypeScript 5.0+) is designed for projects using a bundler
like Vite, esbuild, or webpack. It:
- Allows extensionless relative imports (like classic node)
- Respects
package.json"exports"fields (like node16) - Does NOT require
.jsextensions in source files - Is the recommended choice for Next.js, Vite, and most modern frontend projects
// tsconfig.json — optimal for Vite / Next.js
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true
}
}
Path Aliases (paths)
The paths option maps import specifiers to file system locations. This is used
for clean import aliases like @components/Button instead of
../../components/Button.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"],
"@config": ["src/config/index.ts"]
}
}
}
// Before paths — fragile relative import
import { Button } from '../../../../components/ui/Button';
// After paths — clean and stable
import { Button } from '@components/ui/Button';
paths are compile-time only. Your bundler (webpack, Vite, esbuild) or runtime (Node.js with --experimental-specifier-resolution) must also be configured to resolve the same aliases at build/run time.Package.json "exports" Field
Modern packages use the "exports" field in package.json to control which
files are importable and how they differ between CommonJS and ESM consumers.
TypeScript 4.7+ respects this field when moduleResolution is node16,
nodenext, or bundler.
// package.json of a library
{
"name": "my-lib",
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
},
"./utils": {
"import": { "types": "./dist/utils.d.ts", "default": "./dist/utils.js" },
"require": { "types": "./dist/utils.d.cts", "default": "./dist/utils.cjs" }
}
}
}
// Consumer — TypeScript resolves types via the "types" condition
import { foo } from 'my-lib'; // uses ./dist/index.d.ts
import { bar } from 'my-lib/utils'; // uses ./dist/utils.d.ts
import { baz } from 'my-lib/internal'; // ✗ Error — not in exports map
typeRoots and types
TypeScript automatically includes type definitions from @types/* packages in
node_modules. You can control which packages are included with typeRoots
and types.
// tsconfig.json
{
"compilerOptions": {
// Only look for @types packages in these directories (default: node_modules/@types)
"typeRoots": ["./node_modules/@types", "./types"],
// Only include these specific @types packages (not all of them)
"types": ["node", "jest", "react"]
}
}
types array to whitelist only the @types packages your project actually needs.resolveJsonModule
// tsconfig.json
{
"compilerOptions": {
"resolveJsonModule": true
}
}
// config.json
{ "apiUrl": "https://api.example.com", "timeout": 5000 }
// app.ts — fully typed after enabling resolveJsonModule
import config from './config.json';
// config.apiUrl: string, config.timeout: number
console.log(config.apiUrl.toUpperCase()); // string method available ✓
allowJs and checkJs
// tsconfig.json — incrementally adopt TypeScript in a JS project
{
"compilerOptions": {
"allowJs": true, // TypeScript can import plain .js files
"checkJs": true, // type-check .js files too (using JSDoc types)
"maxNodeModuleJsDepth": 1
}
}
Debugging Resolution Problems
When you get "Cannot find module" errors, use tsc --traceResolution to see
exactly what TypeScript is looking for and where it is looking:
# Trace resolution for every import in your project
npx tsc --noEmit --traceResolution 2>&1 | grep -A5 "your-module-name"
# Or check a single file
npx tsc --noEmit --traceResolution --listFiles 2>&1 | head -100
======== Resolving module './utils' from 'src/app.ts'. ========
Module resolution kind is not specified, using 'Node10'.
Loading module as file / folder, candidate module location 'src/utils'.
File 'src/utils.ts' exists - use it.
======== Module name './utils' was successfully resolved to 'src/utils.ts'. ========
Common Resolution Errors and Fixes
Error | Cause | Fix |
|---|---|---|
Cannot find module X | File does not exist / wrong path | Check spelling, check paths config |
Could not find declaration file for X | Package has no types | Install @types/X or add // @ts-ignore |
Relative import path must use .js | node16/nodenext mode | Add .js extension to relative imports |
Cannot find module — but file exists | paths not configured in bundler | Mirror tsconfig paths in bundler config |
Type X is not assignable — wrong version | Two copies of @types/X | Deduplicate with npm dedupe |
Monorepo Resolution with Project References
// packages/web/tsconfig.json — reference a sibling package
{
"compilerOptions": {
"composite": true,
"paths": {
"@myapp/shared": ["../shared/src/index.ts"]
}
},
"references": [
{ "path": "../shared" }
]
}
// packages/web/src/app.ts
import { SharedUtil } from '@myapp/shared'; // resolves via paths ✓
Summary
Set moduleResolution: "bundler" for Vite/Next.js projects, "node16" for Node.js ESM.
Relative imports always start with ./ or ../ and resolve from the importing file.
Non-relative imports search node_modules up the directory tree.
Use the paths option to create clean import aliases like @components/*.
Remember to mirror tsconfig paths in your bundler config — TypeScript paths are compile-time only.
Use tsc --traceResolution to debug Cannot find module errors.
Respect package.json exports fields — they control what is importable from a package.