Module Resolution
Module resolution is the process by which TypeScript turns an import specifier like "./utils" or "lodash" into an actual file on disk. The strategy you pick must mirror how your runtime (Node.js, a bundler, Deno) loads modules, or the types you see at compile time will not match what runs in production. Modern TypeScript offers node16/nodenext for real Node.js ESM and bundler for build tools like Vite, esbuild, and webpack. This page explains the strategies, how relative vs bare specifiers resolve, and how package.json fields drive everything.
Relative vs bare specifiers
There are two kinds of import specifiers. Relative specifiers start with ./ or ../ and resolve against the importing file. Bare (or non-relative) specifiers name a package and resolve through node_modules and package.json.
import { area } from "./geometry.js"; // relative — a file next to this one
import { z } from "zod"; // bare — a package in node_modules
import { join } from "node:path"; // bare — a Node built-in
How each form maps to a file depends entirely on the moduleResolution strategy.
Choosing a strategy
Set moduleResolution (it pairs with module). The right choice is dictated by your runtime, not by preference.
| Strategy | Use when | Extensions in imports | Reads package exports |
|---|---|---|---|
node16 / nodenext | Code runs directly in Node.js | Required (.js) | Yes |
bundler | A bundler (Vite, esbuild, webpack) builds it | Optional | Yes |
node10 (node) | Legacy CommonJS projects only | Optional | No (legacy) |
// tsconfig.json — running natively in Node.js ESM
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}
nodenexttracks the latest Node.js behavior, whilenode16pins to Node 16 semantics; in practice prefernodenextfor new projects. The legacynode/node10mode does not understand theexportsfield and should be avoided.
node16 / nodenext
These modes implement Node.js’s real ESM algorithm. The decisive detail: relative imports must include a file extension, and the extension is .js even when the source is .ts, because the compiler does not rewrite paths.
// with module/moduleResolution = nodenext
import { area } from "./geometry.js"; // ✅ points at geometry.ts, emits to geometry.js
// ❌ Error: Relative import paths need explicit file extensions
import { area } from "./geometry";
Whether a file is treated as ESM or CommonJS is decided by the nearest package.json "type" field ("module" = ESM) or the file extension (.mts/.cts).
bundler resolution
If a bundler processes your code, use moduleResolution: "bundler". It understands package.json exports/imports like Node, but relaxes the extension requirement because the bundler resolves paths itself.
// tsconfig.json — bundled app (Vite, Next.js, esbuild)
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"noEmit": true
}
}
// extension optional under "bundler"
import { area } from "./geometry";
import Button from "@/components/Button";
This is the right setting for most front-end apps and libraries built with a bundler.
How packages are resolved
For bare specifiers, modern strategies consult the package’s package.json "exports" field, which maps subpaths and selects different files for import vs require and for types.
// node_modules/some-lib/package.json
{
"name": "some-lib",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
The "types" condition tells TypeScript which declaration file to load. If a package only ships "main"/"types" (no exports), TypeScript falls back to those legacy fields.
Use
pathsandbaseUrlin tsconfig to define import aliases (like@/), but remember those are compile-time only — your runtime or bundler must be configured to resolve the same aliases. See paths & baseUrl.
Best Practices
- Match
moduleResolutionto your runtime:nodenextfor native Node,bundlerfor build tools. - Avoid the legacy
node/node10mode; it ignores thepackage.jsonexportsfield. - Under
nodenext, always write the.jsextension in relative imports. - Set
moduleandmoduleResolutionconsistently (nodenext/nodenext). - Rely on the
exportsfield for correct ESM/CJS and types selection in your own packages. - Configure bundler/runtime aliases to mirror any tsconfig
pathsyou define.