It Compiles. It Crashes at Runtime.

tsc exits clean. Then node dist/index.js throws Error: Cannot find module '@/lib/db', or worse, resolves to the wrong file entirely and fails somewhere unrelated twenty seconds into the process. Nothing about the type checker was wrong โ€” it was answering a different question than the one that matters at runtime. moduleResolutionis the setting that decides whether those two questions are actually the same question. Get it wrong and you can pass every type check in CI and still ship a build that doesn't run.

๐Ÿค” Sound familiar?
  • Your editor resolves an import fine but node throws ERR_MODULE_NOT_FOUND on the same line
  • You installed a package with a package.json "exports" field and TypeScript can't find its types
  • Someone on the team asks "should this be nodenext or bundler" and nobody's sure
  • A @/ path alias works in the dev server but breaks the moment you run the compiled output directly

moduleResolution exists to make TypeScript's idea of "where does this import point" match whatever will actually execute the code โ€” Node, or a bundler pretending to be Node. When the two drift, the compiler stops being useful for the one thing you need it to catch.

Four Strategies, Two That Still Matter

classic predates Node module resolution entirely and only shows up now behind module: amd or system โ€” if you're not building for one of those, ignore it. node (also called node10) modeled Node's CommonJS algorithm circa Node 10, back before package.json"exports" maps existed. TypeScript 6.0 deprecated it outright, with a straight line in the release notes: migrate to nodenextif you're targeting Node directly, or bundler if a bundler is doing the resolving. Those two are the real decision.


flowchart TD
    Imp["import { x } from 'pkg'"] --> M{"moduleResolution"}
    M -->|"node / node10 (deprecated in TS 6.0)"| N1["Pre-2020 CJS algorithm โ€”<br/>ignores package.json exports"]
    M -->|"node16 / nodenext"| N2["Real Node ESM+CJS rules โ€”<br/>requires explicit file extensions"]
    M -->|"bundler"| N3["Hybrid โ€” respects exports,<br/>allows extensionless imports"]
    N1 --> R1["Wrong file, or types<br/>missing entirely"]
    N2 --> R2["Matches what 'node' does<br/>at runtime"]
    N3 --> R3["Matches what Vite/esbuild/webpack<br/>do at build time"]

    style N1 fill:#dc2626,color:#fff,stroke:#b91c1c
    style N2 fill:#16a34a,color:#fff,stroke:#15803d
    style N3 fill:#0078D4,color:#fff,stroke:#005a9e

Why node10 Breaks on Modern ESM Packages

A package published today typically looks like this โ€” no top-level main, everything routed through exports:

{
  "name": "modern-lib",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

node10's algorithm has no concept of exports โ€” it was frozen before that field existed. It falls back to guessing at an index.jsin the package root, doesn't find one, and either errors or, worse, silently resolves to a different file than Node will actually load. The types and the runtime disagree, and TypeScript has no way to tell you that.

bundler Mode

Introduced in TypeScript 5.0 specifically because node16/nodenextenforce rules real bundlers don't care about โ€” extensionless relative imports, for one, which Vite, esbuild, and webpack all handle fine but Node's own resolver rejects. bundler respects exportsconditions while staying loose about extensions, which is exactly the hybrid behaviour those tools actually implement. If you're shipping an application through one of them, this is the default you want, not node16.

// App built with Vite / esbuild / webpack
{ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler" } }

// A package that runs directly under Node, no bundler involved
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }

node16 / nodenext for Real Node Authoring

If you're publishing a package that Node itself will load with no bundling step, nodenextis the one that keeps you honest. It requires relative imports to carry an explicit extension โ€” and it's the output extension, .js, even though the file you're importing is .ts.

import { formatDate } from './utils.js'; // โœ… required โ€” resolves to utils.ts at compile time
import { formatDate } from './utils';    // โŒ Error: relative import paths need explicit file extensions

// .mts / .cts force a file's module format regardless of the nearest package.json "type"
// date-utils.mts always compiles to date-utils.mjs (ESM)
// legacy-shim.cts always compiles to legacy-shim.cjs (CommonJS)

paths and baseUrl Are Compile-Time Only

This is the one that surprises people who've only ever run code through a dev server. tsc checks a @/ alias fine and then emits the import completely unchanged. It doesn't rewrite anything โ€” resolving aliases was never tsc's job, it was always the bundler's or the loader's.

// tsconfig.json
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

// src/api/orders.ts
import { db } from '@/lib/db'; // typechecks, autocompletes, looks completely normal

// dist/api/orders.js โ€” tsc emitted this literally, alias and all
import { db } from '@/lib/db';

// $ node dist/api/orders.js
// Error: Cannot find module '@/lib/db'

A bundler papers over this because it reads the same paths config (often through a plugin) and rewrites the alias into a real relative path as part of the build. Running raw tsc output through plain Node gets none of that. The fix is one of: let the bundler own resolution end to end, run something like tsc-aliasas a post-build rewrite step, or skip the alias for anything that ships as plain Node output and use Node's own package.json "imports" field โ€” "#lib/db"โ€” which Node resolves natively, because it isn't a TypeScript convention at all.

Key Takeaways

  • node10 predates package.json exports maps entirely โ€” that's the whole reason TypeScript 6.0 deprecated it, not a stylistic preference.
  • bundler mode is the right default for app code going through Vite, esbuild, or webpack; nodenext is for packages that run directly under Node with nothing in between.
  • paths and baseUrl are a type-checking convenience only โ€” tsc never touches emitted import paths, so something else has to resolve them at runtime.
  • When an import typechecks but fails at runtime, the mismatch is almost always here: what tsc assumed will resolve the module versus what actually will.