Skip to content
TypeScript ts modules 3 min read

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.

StrategyUse whenExtensions in importsReads package exports
node16 / nodenextCode runs directly in Node.jsRequired (.js)Yes
bundlerA bundler (Vite, esbuild, webpack) builds itOptionalYes
node10 (node)Legacy CommonJS projects onlyOptionalNo (legacy)
// tsconfig.json — running natively in Node.js ESM
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext"
  }
}

nodenext tracks the latest Node.js behavior, while node16 pins to Node 16 semantics; in practice prefer nodenext for new projects. The legacy node/node10 mode does not understand the exports field 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 paths and baseUrl in 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 moduleResolution to your runtime: nodenext for native Node, bundler for build tools.
  • Avoid the legacy node/node10 mode; it ignores the package.json exports field.
  • Under nodenext, always write the .js extension in relative imports.
  • Set module and moduleResolution consistently (nodenext/nodenext).
  • Rely on the exports field for correct ESM/CJS and types selection in your own packages.
  • Configure bundler/runtime aliases to mirror any tsconfig paths you define.
Last updated June 29, 2026
Was this helpful?