TypeScript config and tooling · 4 / 8
lesson 4

Module resolution

How TypeScript turns 'import x from "y"' into an actual file on disk.

~ 14 min read·lesson 4 of 8
0 / 8

You write import { Cart } from './cart'. Somewhere between that line and the running program, TypeScript has to figure out which file on disk is ./cart. Is it cart.ts? cart.tsx? cart/index.ts? cart.js? The rules for that lookup are called module resolution, and which rules apply depends on a single tsconfig flag.

This lesson is the rules. Once you know them, "Cannot find module" stops being mysterious.

Two flavors of import

There are two shapes of import path, and they get resolved by different parts of the algorithm.

A relative import starts with ./ or ../:

src/checkout.ts
import { Cart } from './cart';
import { format } from '../lib/format';

The compiler looks for those files relative to the current file. ./cart from src/checkout.ts means "look in src/ for something called cart".

A bare import has no leading dots:

src/checkout.ts
import { z } from 'zod';
import express from 'express';

Bare imports are package names. The compiler walks up from the current file looking for a node_modules/zod folder, then reads that package's manifest to figure out which file is the entry point.

The same moduleResolution setting governs both. Different settings give different rules for both.

node — the classic algorithm

moduleResolution: "node" is the original style, modeled after how Node.js historically loaded modules.

For relative imports, it tries each of these paths in order until one exists:

  • ./cart.ts
  • ./cart.tsx
  • ./cart.d.ts
  • ./cart/index.ts
  • ./cart/index.tsx
  • ./cart/index.d.ts

For bare imports like zod, it walks up the folder tree:

  • ./node_modules/zod/
  • ../node_modules/zod/
  • ../../node_modules/zod/

…and so on until it hits the filesystem root. Once it finds the package folder, it reads the package's package.json and follows the "types" or "main" field to the actual entry file.

You wrote no extension. The algorithm tried several. That auto-extension behavior is the defining feature of node resolution.

bundler — the modern app default

moduleResolution: "bundler" is the rule TypeScript ships for projects that go through Vite, Webpack, esbuild, Rollup, or any other bundler.

It is essentially node with the modern defaults a bundler already applies:

  • Auto-extension lookup, like node.
  • Reads "exports" field in package.json (the modern way to declare what a package exposes).
  • Does not care which file extension you use — .ts and .js are interchangeable in import paths because the bundler handles both.
  • Allows path aliases without ceremony.
tsconfig.json
{
"compilerOptions": {
  "module": "ESNext",
  "moduleResolution": "bundler"
}
}

For new web app projects bundled with Vite or similar, this is the right choice. It models exactly what your bundler does, so the editor never disagrees with the build.

Tip

bundler resolution requires module to be one of the ESM values — ESNext or ES2022. module: "CommonJS" with moduleResolution: "bundler" is rejected.

check your understanding
You're starting a new Vite project. Which moduleResolution matches what Vite actually does at runtime?

nodenext — the strict ESM mode

moduleResolution: "nodenext" models how native Node.js loads modules when you set "type": "module" in package.json. It is strict in ways node is not.

The two rules that bite first:

  • Relative imports must include the file extension. import './cart.js', not import './cart'. Even when the file on disk is cart.ts, you import it as cart.js because that is what the runtime will resolve.
  • Bare imports follow the package's "exports" field strictly. If the package doesn't export the subpath you tried, the import fails — even if the file exists on disk.
src/index.ts
// nodenext requires this:
import { Cart } from './cart.js';

// not this:
import { Cart } from './cart';     /* error: relative import needs .js */
import { Cart } from './cart.ts';  /* error: never write .ts in source */

The .js looks wrong — the file is cart.ts. It is correct. TypeScript compiles cart.ts to cart.js, and the runtime resolves the .js extension. The compiler is happy because it knows to map .js back to a sibling .ts file at type-check time.

Use nodenext for libraries and Node CLIs targeting modern Node ESM. Use bundler for web apps. Mixing them up is one of the most common config mistakes.

ESM and CJS, briefly

Two module systems live in the JavaScript world.

CommonJS (CJS) is the older Node format: require('foo') and module.exports = .... Synchronous, dynamic, what older Node code uses.

ESM (ECMAScript Modules) is the standard format: import foo from 'foo' and export .... Static, what browsers natively support, what Node now supports too.

Most packages on npm ship both. The package's package.json tells the resolver which to use:

node_modules/zod/package.json
{
"name": "zod",
"type": "module",
"main": "./lib/index.cjs",
"exports": {
  ".": {
    "import": "./lib/index.mjs",
    "require": "./lib/index.cjs",
    "types": "./lib/index.d.ts"
  }
}
}

When you import the package, the "import" branch is used. When you require it, the "require" branch is used. The "types" branch is what TypeScript reads to know the shape.

This is mostly invisible to you — until a package ships its types only on one branch and your tsconfig is set up for the other. That is when "Cannot find module" appears for a package that is clearly installed.

Watch out

Symptoms of an ESM/CJS mismatch: the package's runtime imports work, but the editor underlines the import as untyped. Fix: switch moduleResolution to bundler or nodenext, both of which read modern exports maps.

check your understanding
A package's package.json has an exports field but no main. With moduleResolution: "node", what happens when you import it?

What "cannot find module" actually means

This is the most common error in this lesson. The message is misleading; "cannot find" suggests the file is missing, but usually the file exists and the algorithm just gave up at a step.

terminal output
error TS2307: Cannot find module './cart' or its corresponding type declarations.

Walk through the algorithm yourself when this happens.

  1. Is the path relative? If ./cart, the compiler looked in the same folder as the importing file. Check the actual folder.
  2. Did you include cart in tsconfig.json's include pattern? Files outside include are invisible. A common foot-gun: include: ["src"] plus a file at lib/cart.ts.
  3. Is the extension expected? On nodenext, import './cart' fails because no extension is given. On bundler or node, it succeeds.
  4. For bare imports, is the package installed? Look in node_modules. If yes — does it ship types? Some packages need a separate @types/foo install.
  5. For bare imports with types — do moduleResolution and the package's exports agree? See the previous section.
import './cart'cart.ts cart.tsx cart.d.ts match cart/index.ts cart/index.d.tsno match → TS2307
Resolution walks down a list. The first match wins; if nothing matches, you get TS2307.

The figure is the lookup laid out. The first candidate that exists on disk wins. If the list runs out, the compiler gives up.

check your understanding
You're getting "Cannot find module './utils'" for a file you can clearly see at tools/utils.ts. The importer is src/index.ts. The path is wrong, but what specifically?
check your understanding
You install a fresh package and the import works at runtime but the editor types it as any. The package has no @types/ shim on npm. What is the most likely root cause?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript config and tooling
0 of 8 read