open atlas
↑ Back to track
TypeScript type system, deep TS · 07 · 02

module & moduleResolution: how an import string becomes a file

`module` and `moduleResolution` decide how import strings become files — the classic 'works in the editor, fails at runtime' bug. `node16`/`nodenext` enforce real Node ESM rules; `bundler` (TS 5.0) relaxes them. `tsc` never rewrites `paths` — the bundler must.

TS Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

The editor was green. import { parse } from "./parser" resolved, autocompleted, type-checked. CI built it. Then production crashed: ERR_MODULE_NOT_FOUND: Cannot find module '/app/dist/parser'. The project was ESM ("type": "module"), and Node ESM requires the .js extension in the import — but moduleResolution was set to node, an old mode that doesn’t enforce that rule. TypeScript happily resolved a path that Node would never load.

Two settings, two jobs

When you see ERR_MODULE_NOT_FOUND in production on code that type-checked fine, the culprit is almost always here: two settings that look related but control completely different things.

module decides the syntax TypeScript emits (require/exports for CommonJS, import/export for ES2020+). moduleResolution decides the algorithm that turns an import specifier into a file on disk. They are separate, and the modern advice is to set module and let moduleResolution follow from it:

{
  "compilerOptions": {
    "module": "nodenext",          // emit ESM or CJS per package.json "type"
    "moduleResolution": "nodenext" // resolve the way Node 16+ actually does
  }
}

The available resolution modes, oldest to newest:

moduleResolutionwalkshonours package.json exports?.js ext required in ESM?when
classicrelative + ambient onlynononever (legacy, pre-1.6)
node (a.k.a. node10)node_modules like CJS requirenonoold CJS projects only
node16CJS or ESM per file’s package typeyesyes (in ESM files)Node, pinned to 16 behaviour
nodenextsame as node16, tracks newest Nodeyesyes (in ESM files)Node libraries/apps today
bundler (TS 5.0)exports + extensionless, no .js ruleyesnoesbuild / Vite / webpack

How resolution walks: bare specifiers and exports

For a relative import (./parser) the resolver tries files next to the importer. For a bare specifier (lodash, @scope/pkg) it walks up node_modules directories, then consults the package’s package.json. Under node16/nodenext/bundler it reads the exports field and picks a target by matching conditionsimport vs require, types, node, default:

// node_modules/@acme/sdk/package.json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",   // chosen for ESM `import`
      "require": "./dist/index.cjs"   // chosen for CJS `require`
    }
  }
}

exports is an encapsulation boundary: if a path isn’t listed, it can’t be imported, even if the file exists. import "@acme/sdk/internal/util" fails unless ./internal/util is in exports. The old node mode ignores exports entirely, which is exactly why a package resolves under node but breaks under nodenext (or vice-versa).

The surprising rule: .js extensions on TS imports

Under node16/nodenext, in an ESM file you must write the import with a .js extension even though the source file is parser.ts:

// src/index.ts — ESM (package.json "type": "module")
import { parse } from "./parser.js"; // ✅ resolves parser.ts, emits import "./parser.js"
import { parse } from "./parser";    // ❌ TS2835: relative import needs ".js" extension
import { parse } from "./parser.ts"; // ❌ TS5097: an import path can't end with ".ts"

You write the output extension (.js), TypeScript checks against the source (.ts), and the emit is left untouched so Node finds the real file at runtime. bundler mode turns this requirement off because bundlers resolve extensionless imports themselves.

Why this works

Why force .js and not .ts? Because TypeScript’s design rule is that it never rewrites import specifiers in emit (with the lone exception of changing the import form for module: commonjs). The string you write is the string Node sees. Since Node ESM requires the extension and the file on disk is parser.js after compilation, you must write parser.js. Writing parser.ts would emit parser.ts, which doesn’t exist at runtime.

paths aliases: tsc checks them, doesn’t emit them

baseUrl/paths let you write import { db } from "@/db" instead of ../../../db. Critically, tsc resolves these for type-checking but does not rewrite them in emitted JS — the literal "@/db" is left in the output. At runtime, Node has no idea what @/db is.

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

So paths only works if something else rewrites the alias: a bundler (Vite, esbuild, webpack with a matching resolve.alias), or a runtime loader (tsconfig-paths, or a package.json imports field). The classic failure: type-checks and bundles fine via the dev server, then a plain node dist/index.js throws Cannot find module '@/db'.

Interop: esModuleInterop and verbatimModuleSyntax

CommonJS has no real default export; module.exports = x is a whole-namespace assignment. esModuleInterop: true synthesises a default so import express from "express" works against a CJS module, and emits __importDefault helpers. Without it you’d need import express = require("express") or import * as express.

verbatimModuleSyntax (TS 5.0, replacing importsNotUsedAsValues and the emit half of isolatedModules concerns) makes emit literal: a regular import/export is emitted as-is, and only import type / export type are erased. This forces you to be explicit about what’s a type-only import, which is exactly what single-file transpilers need:

import { type User, createUser } from "./user.js";
// emit: import { createUser } from "./user.js";  — `User` erased, `createUser` kept verbatim
Order the steps

Order the steps node16/nodenext takes to resolve import { x } from '@acme/sdk' in an ESM file:

  1. 1 See the bare specifier @acme/sdk (not relative), so search node_modules
  2. 2 Walk up node_modules directories until @acme/sdk/package.json is found
  3. 3 Read the package's exports field instead of guessing main/index
  4. 4 Match the import condition (ESM context) to pick ./dist/index.mjs and its types
Quiz

An ESM project type-checks in VS Code but `node dist/index.js` throws Cannot find module './parser'. The tsconfig has moduleResolution: 'node'. What is the root cause?

Recall before you leave
  1. 01
    What is the difference between `module` and `moduleResolution`, and why does the wrong mode cause 'works in editor, fails at runtime'?
  2. 02
    Under node16/nodenext, why must you write `./parser.js` to import a file called parser.ts, and what general rule does this follow?
  3. 03
    Why do `paths`/`baseUrl` aliases work in the editor and bundler but break under plain `node dist/index.js`?
Recap

You can now reason about how an import string becomes a file: module emits the syntax, moduleResolution finds the target, exports/conditions gate package access, the .js-extension rule trips ESM newcomers, and paths need a bundler to survive emit. Next, declaration files — once your code resolves, how do you ship the types for it? .d.ts files, ambient and module declarations, and the augmentation traps that silently fail to merge. Now when you see ERR_MODULE_NOT_FOUND in production on code that the editor accepted, your first question is: does moduleResolution match the runtime, and did you write the .js extension on that relative import?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.