Introduction
Module resolution is how TypeScript decides what file an import statement refers to. The strategy you choose determines whether TypeScript requires file extensions, respects package.json exports, and how it searches node_modules. TypeScript 5.9 introduced new strategies that align with modern Node.js ESM behavior.
Key Concepts
- Module Resolution Strategy: The algorithm TypeScript uses to map an import specifier to a file on disk.
- node10 (legacy Node): The classic Node.js CommonJS resolution — checks
index.js, allows extensionless imports. - node16 / nodenext: ESM-aware resolution that requires file extensions and respects
package.jsonexports. - bundler: For Vite, Webpack, esbuild — understands ESM imports but delegates final resolution to the bundler.
Real World Context
The Node.js ecosystem is migrating from CommonJS to ESM. TypeScript's resolution strategies must match your runtime and toolchain. Using the wrong strategy causes "module not found" errors that are confusing because the file clearly exists — TypeScript just was not looking for it correctly.
Deep Dive
Strategy Comparison
| Strategy | Extensions Required | Respects exports | Best For |
|---|---|---|---|
node10 (Node) | No | No | Legacy CJS projects |
node16 / nodenext | Yes (.js) | Yes | Modern Node.js (ESM) |
bundler | No | Yes | Vite, Webpack, esbuild |
node10 (Legacy)
json{ "moduleResolution": "Node" }
This is the classic strategy. It checks ./file.ts, ./file/index.ts, and node_modules without requiring extensions. It does NOT respect package.json exports fields.
node16 / nodenext
json{ "module": "NodeNext", "moduleResolution": "NodeNext" }
This strategy mirrors modern Node.js behavior:
typescript// Must use .js extension (even though the source is .ts) import { helper } from "./utils.js"; // Package imports respect the "exports" field import { z } from "zod"; // Resolves via zod's package.json exports
The .js extension requirement catches developers off-guard: you write .ts files but import with .js extensions because TypeScript resolves them to the corresponding .ts file during compilation.
bundler
json{ "module": "ESNext", "moduleResolution": "Bundler" }
For bundler-based projects (Vite, Webpack):
- No file extensions required in imports
- Respects
package.jsonexports - Does not enforce Node.js-specific rules
Path Mapping
Map custom import paths to directories:
json{ "compilerOptions": { "baseUrl": ".", "paths": { "@utils/*": ["src/utils/*"], "@components/*": ["src/components/*"] } } }
typescriptimport { formatDate } from "@utils/date"; // Resolves to src/utils/date.ts
Common Pitfalls
- Using node10 with ESM packages — Modern packages with
exportsfields will not resolve correctly with the legacy strategy. - Forgetting .js extensions with nodenext — TypeScript requires
.jsextensions in import paths even for.tssource files. This is by design to match Node.js runtime behavior.
Best Practices
- Match strategy to your runtime — Use
NodeNextfor Node.js apps,Bundlerfor frontend projects with Vite/Webpack. - Always pair module and moduleResolution —
"module": "NodeNext"requires"moduleResolution": "NodeNext". Mismatches cause confusing errors.
Summary
node10is the legacy strategy for CommonJS — no extensions, no exports support.node16/nodenextrequires.jsextensions and respectspackage.jsonexports.bundleris ideal for Vite/Webpack — no extensions required but exports are respected.- Always pair
moduleandmoduleResolutionsettings.
Code Examples
// With moduleResolution: "NodeNext"
// Source file: src/utils/date.ts
// Import MUST use .js extension (TypeScript resolves .js → .ts)
import { formatDate } from "./utils/date.js";
// Package imports resolve via package.json "exports"
import { z } from "zod";
// With moduleResolution: "Bundler" — no extension needed
import { formatDate } from "./utils/date";