TypeScriptModule Resolution

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:

  1. Locate the file — apply the resolution algorithm to find the .ts, .tsx, or .d.ts file that declares the module.
  2. Type-check the import — verify that x is 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

JSON
// 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"
  }
}
      
Note
The 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:

  1. Exact file match: try './utils.ts', then './utils.tsx', then './utils.d.ts'

  2. Directory index: try './utils/index.ts', './utils/index.tsx', './utils/index.d.ts'

  3. For non-relative: walk up node_modules/ directories until the root

  4. Inside node_modules/pkg/: check package.json "types" or "typings" field

  5. Fall back to node_modules/pkg/index.d.ts

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.

TS
// 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" }
//   }
// }
      
Warning
In 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 .js extensions in source files
  • Is the recommended choice for Next.js, Vite, and most modern frontend projects

JSON
// 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.

JSON
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@utils/*":      ["src/utils/*"],
      "@config":       ["src/config/index.ts"]
    }
  }
}
      

TS
// Before paths — fragile relative import
import { Button } from '../../../../components/ui/Button';

// After paths — clean and stable
import { Button } from '@components/ui/Button';
      
Note
TypeScript 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.

JSON
// 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" }
    }
  }
}
      

TS
// 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.

JSON
// 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"]
  }
}
      
Tip
If you are getting unexpected global type pollution (e.g. Node.js globals appearing in browser code), use the types array to whitelist only the @types packages your project actually needs.
resolveJsonModule

JSON
// tsconfig.json
{
  "compilerOptions": {
    "resolveJsonModule": true
  }
}
      

TS
// 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

JSON
// 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:

Bash
# 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

JSON
// packages/web/tsconfig.json — reference a sibling package
{
  "compilerOptions": {
    "composite": true,
    "paths": {
      "@myapp/shared": ["../shared/src/index.ts"]
    }
  },
  "references": [
    { "path": "../shared" }
  ]
}
      

TS
// 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.