TypeScript Module Resolution Explained
TypeScript's moduleResolution setting decides how import paths map to files. How node10, node16, and bundler resolution differ, and which to pick.
Module resolution is the process TypeScript uses to turn an import specifier like import { thing } from "./utils" into an actual file on disk, so it can find that file’s types and check your code against them. It sounds mechanical, but the algorithm TypeScript uses has changed over the years, and picking the wrong moduleResolution setting in tsconfig.json is one of the most common sources of “it works at runtime but TypeScript won’t compile” confusion.
Why resolution needs a setting at all
At runtime, Node.js and bundlers each have their own rules for turning an import path into a file: whether extensions are required, whether package.json’s exports field is respected, how a bare specifier like lodash gets resolved into node_modules. TypeScript has to mimic whichever runtime or bundler will actually execute the code, or its type checking will pass on code that fails to run — or, just as often, reject correct code because its resolution algorithm doesn’t match reality.
That’s what the moduleResolution compiler option in tsconfig.json controls: which algorithm the type checker uses to go from an import specifier to a file and its types.
The main strategies
node10(formerly justnode) — TypeScript’s original algorithm, modeled on how Node.js resolved CommonJS modules before theexportsfield existed. It ignorespackage.json’sexportsmap entirely, which means it can resolve paths that would actually fail at runtime in a modern Node.js ESM project. It’s still the default in many configurations for backward compatibility, but it’s increasingly a source of false confidence.node16/nodenext— added to match how Node.js actually resolves modules oncepackage.jsondeclares"type": "module"or usesexportsmaps. This mode respects theexportsfield, enforces the file extension rules real Node.js ESM enforces (an explicit.jsextension is required in relative imports, even when importing a.tsfile), and distinguishes CommonJS from ESM per-file based onpackage.jsonand file extensions. It’s stricter, but strictness here means fewer surprises at runtime.bundler— designed for projects that will never run through Node’s module loader directly, because a bundler like esbuild, Rollup, or webpack handles resolution instead. It relaxes the extension requirementsnode16enforces (you can still write./utilswithout.js) while still respectingpackage.jsonexportsmaps, matching how most JavaScript bundlers actually behave.
A quick comparison
| node10 | node16 / nodenext | bundler | |
|---|---|---|---|
Respects exports map | No | Yes | Yes |
| Requires explicit extensions in relative imports | No | Yes | No |
| Matches plain Node.js ESM | No | Yes | Not exactly |
| Matches typical bundler behavior | Partially | No | Yes |
| Best for | Legacy CommonJS-only projects | Node.js apps/libraries targeting ESM | Apps built with Vite, esbuild, webpack, etc. |
Picking the right one
The setting should match how the code is actually going to run, not just how it’s written:
- Building a Node.js library or backend service that ships as ESM or needs to interoperate correctly with both module systems: use
node16ornodenext, paired with"module": "node16"or"nodenext". This is the option that will catch resolution mistakes before they become runtime errors in production. - Building a frontend app with Vite, esbuild, or webpack, where the bundler — not Node.js — resolves every import: use
bundler. It matches what the bundler will actually do and won’t force you to add file extensions the bundler doesn’t require. - Maintaining an older codebase that’s fully CommonJS and has no plans to adopt
exportsmaps or ESM:node10still works, though it’s worth knowing it won’t catch resolution errors that a strict Node.js ESM environment would.
Mismatches show up in predictable ways: with node10, an import that works during type-checking can throw ERR_MODULE_NOT_FOUND at runtime because the real Node.js resolver respects exports restrictions the type checker ignored. With node16, developers coming from bundler-style projects are often surprised the compiler demands a .js extension on a relative import to a .ts file — that’s intentional, since after compilation the file really will be .js, and Node’s ESM loader needs the extension present in the source to resolve it correctly at runtime.
How this interacts with module and target
moduleResolution doesn’t work in isolation — it’s typically set alongside module, which controls what kind of module syntax TypeScript emits, and target, which controls what JavaScript language level it compiles down to. Setting module: "nodenext" implies moduleResolution: "nodenext" automatically in recent TypeScript versions, which removes one manual step, but it’s worth understanding both settings independently rather than relying on the implication, since debugging resolution errors means knowing which algorithm is actually in effect.
This also matters when choosing between package managers: how strictly exports maps and node_modules layout are enforced can differ subtly between npm, pnpm, and Yarn, which occasionally surfaces resolution issues that only appear with one package manager and not another, independent of the moduleResolution setting itself.
The takeaway
TypeScript’s moduleResolution setting exists because the type checker has to guess which runtime or bundler will eventually load your code, and different guesses produce genuinely different — sometimes contradictory — results for the same import statement. Match the setting to reality: bundler for apps built with a bundler, node16/nodenext for Node.js projects that care about ESM correctness, and node10 only for legacy CommonJS code that isn’t going anywhere. Getting this one setting wrong is a common reason type-checking passes while the actual import fails the moment the code runs.
Keep reading
Takina · · 4 min read What Is Biome? ESLint and Prettier in One Rust Tool
Biome is a Rust-based linter and formatter that replaces ESLint and Prettier with one faster tool, one config file, and no plugin ecosystem to manage.
Takina · · 4 min read tsconfig.json Explained: The Compiler Options That Matter
tsconfig.json controls how TypeScript checks and compiles your code. Here are the options that actually change behavior, and the ones you can leave alone.
Takina · · 5 min read TypeScript 7.0 Released: Go Rewrite, 10x Faster
Microsoft shipped TypeScript 7.0 with a Go-native compiler that's roughly 10x faster than 6.0. What changed, what breaks, and how to upgrade.