Skip to content

TypeScript Module Resolution — Complete Guide

DodaTech Updated 2026-06-28 5 min read

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 exports and imports package.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 index import without ./ prefix in some cases
  • Supports package.json imports and exports

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:

  • .js extensions required in relative imports (even for .ts files)
  • Respects type: "module" and type: "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

  1. What is the difference between node and bundler resolution? bundler relaxes several Node resolution requirements (e.g., no extension restrictions) and supports the exports and imports fields.

  2. How do baseUrl and paths work together? baseUrl sets the root for non-relative imports. paths provides pattern-based remappings relative to baseUrl.

  3. What does traceResolution: true do? Logs every step TypeScript takes to resolve module imports, useful for debugging "cannot find module" errors.

  4. Why does Node16 resolution require .js extensions? 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

What is the default moduleResolution?

Depends on the module setting. For module: "commonjs", default is node. For module: "esnext", default is bundler (TS 5+).

Can I use paths with npm packages?

Yes: "lodash": ["node_modules/lodash"] in paths, but it's unnecessary for installed packages.

What is the `types` field in package.json?

Point tsconfig's types to your generated .d.ts entry file so consumers get type information.

What does `rootDir` affect in resolution?

rootDir controls which directory TypeScript considers the root of your project structure for output mapping.

How does TypeScript resolve `@types/*` packages?

It looks in node_modules/@types/ by default. Configure with typeRoots to change this.

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