Articles

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.

The Lycoris Team The Lycoris Team · · 4 min read
Code editor showing TypeScript configuration

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 just node) — TypeScript’s original algorithm, modeled on how Node.js resolved CommonJS modules before the exports field existed. It ignores package.json’s exports map 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 once package.json declares "type": "module" or uses exports maps. This mode respects the exports field, enforces the file extension rules real Node.js ESM enforces (an explicit .js extension is required in relative imports, even when importing a .ts file), and distinguishes CommonJS from ESM per-file based on package.json and 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 requirements node16 enforces (you can still write ./utils without .js) while still respecting package.json exports maps, matching how most JavaScript bundlers actually behave.

A quick comparison

node10node16 / nodenextbundler
Respects exports mapNoYesYes
Requires explicit extensions in relative importsNoYesNo
Matches plain Node.js ESMNoYesNot exactly
Matches typical bundler behaviorPartiallyNoYes
Best forLegacy CommonJS-only projectsNode.js apps/libraries targeting ESMApps 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 node16 or nodenext, 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 exports maps or ESM: node10 still 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.

Takina 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.

#JavaScript #TypeScript #Developer Tools
Takina 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.

#TypeScript #JavaScript #Developer Tools