Lesson 32 of 80
moduleResolution doesn't decide what code you write — it decides how the compiler finds the file behind an import, and the wrong strategy produces import errors that have nothing to do with your code being wrong.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
When you write import { helper } from "./utils", TypeScript has to turn that string into an actual file on disk before it can check anything. moduleResolution is the setting that controls the algorithm it uses to do that lookup. It sounds like plumbing you'd never need to think about — until the wrong strategy rejects an import that's completely correct, or silently resolves to the wrong file.
The three you'll actually run into are node16 / nodenext (which replicate Node's real ESM resolution rules, including its package.json "type" field and exports map), bundler (added in TypeScript 5.0, which matches how tools like webpack, esbuild, and Vite actually resolve imports), and classic (the old pre-Node default, essentially legacy-only at this point).
This codebase's tsconfig.json sets "moduleResolution": "bundler", because Next.js's build is bundler-driven, not a raw Node ESM runtime. Here's what changes if you flip that to "node16" instead:
// utils.ts exists on disk — but the import doesn't reference the file directly
// with moduleResolution: "node16" (real Node ESM rules):
import { helper } from "./utils"; // ❌ Relative import paths need explicit
// file extensions in ECMAScript imports
import { helper } from "./utils.js"; // ✅ required — even though the source file is utils.ts
// with moduleResolution: "bundler" (what this project actually uses):
import { helper } from "./utils"; // ✅ fine — the bundler resolves the extension itself
Under real Node ESM, an extensionless relative import is simply invalid — Node has no bundler doing that resolution work for it at runtime, so TypeScript enforces the same rule at compile time to match. bundler mode exists precisely because tools like webpack and esbuild do resolve extensionless imports themselves, so forcing the stricter Node rule onto a bundler-based project produces errors about a problem that doesn't actually exist for that build.
moduleResolution doesn't control what import syntax you write. That's the separate module setting (esnext, commonjs, and so on). moduleResolution only controls how the compiler locates the file behind a given import specifier — two independent settings people often conflate.node16/nodenext reads each package's own package.json "type" field, so identical-looking import statements can be legal in one dependency and illegal in another, depending on whether that package declares itself as "module" or "commonjs".bundler was added in TypeScript 5.0 specifically because bundler behavior never matched real Node ESM resolution. Projects built with webpack, esbuild, or Vite were getting either false compile errors (extensionless imports flagged as broken) or missed real ones, because node16 was checking against rules the actual runtime environment didn't follow.classic is essentially a legacy setting. It predates Node's module system entirely and resolves relative imports differently from every modern tool — you'll mostly encounter it in very old configs, not something to reach for in new projects.Module resolution and the type-only-import rules covered elsewhere in this track come from the same family of "how does the type checker talk to whatever actually runs the code" problems — both exist because a single-file transpiler or bundler can't see the whole picture the way tsc itself can, so TypeScript has to be told explicitly what to assume.