Right, let’s demystify how TypeScript finds your code. This is where most people’s brains go numb, and honestly, for good reason. The interplay between module and moduleResolution is one of the most common sources of “But it worked on my machine!” in the TypeScript world. We’re going to fix that.

Think of module as you telling TypeScript what syntax you’re using (import foo from 'foo'), and moduleResolution as the strategy it uses to find the actual file that 'foo' refers to. They are a package deal. You can’t understand one without the other.

The module Setting: What You Write

This setting dictates the output format of your modules. It answers the question: “What JavaScript module syntax should the compiler generate?” Your choice here is heavily dictated by your target runtime environment.

// tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS", // Good for Node.js
    // "module": "ES2015", // Good for modern browsers (using <script type="module">)
    // "module": "ESNext", // For the very latest JS features (use with bundlers)
  }
}

If you’re building for Node.js, you’ll almost certainly want "CommonJS". If you’re building for the browser and using a bundler like Webpack or Vite, "ES2015" or "ESNext" is your friend. The compiler will transform your fancy ES6 import statements into require() calls if you choose CommonJS. It’s like a translator for your module syntax.

The moduleResolution Setting: How TypeScript Finds It

This is the detective. It tells TypeScript how to take the string in your import statement and find the corresponding .ts or .d.ts file. The two main strategies are "node" and "classic". Let’s be direct: you almost never want "classic". It’s a legacy mode that exists mostly for… well, legacy reasons. It’s the old way TypeScript did things before it adopted Node.js’s brilliantly pragmatic resolution algorithm.

"node" resolution mimics how Node.js itself resolves modules. This is why it’s the default when module is set to CommonJS, AMD, ES2015, ESNext, etc. It’s battle-tested and predictable.

Here’s what the Node resolver does when you write import foo from './bar';:

  1. Look for an exact file: ./bar.ts, ./bar.tsx, ./bar.d.ts.
  2. Look for an index file: ./bar/index.ts, ./bar/index.tsx, ./bar/index.d.ts.
  3. Look for a package.json and its "types" or "main" entry: ./bar/package.json.

It also checks node_modules in a very specific way, crawling up the directory tree. This is why you can import "react" from anywhere in your project.

The Crucial Defaults (Where People Get Tripped Up)

Here’s the kicker, and the designers get a side-eye for this one: the default value for moduleResolution depends on your module setting. It’s implicit. This is a fantastic way to create confusion.

  • If module is CommonJS, AMD, UMD, or System, the default moduleResolution is "node".
  • If module is ES6/ES2015 or later (ESNext), the default moduleResolution is also "node" in modern TypeScript versions (since ~4.7). This is a change from the older behavior!

Wait, what? Yes, the old default for ES6 modules was "classic", which was a truly baffling decision. Thankfully, they fixed it. The takeaway: always explicitly set moduleResolution in your tsconfig.json. Don’t rely on the implicit default. It makes your configuration self-documenting and prevents your project from breaking if you change the module option later.

// A good, explicit, modern config for a Node.js project
{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node",
    "target": "ES2020",
    "strict": true
    // ... other settings
  }
}

The "bundler" Resolution: A New Hope

With the rise of bundlers like Vite, esbuild, and Webpack, a new option has emerged: "moduleResolution": "bundler". This mode is designed for projects that will use a bundler, not Node.js itself, to resolve modules.

It’s like "node" lite. It uses the Node.js algorithm for looking things up, but it relaxes some requirements. The most significant one? It allows you to import modules that are only ES Modules (they have "type": "module" in their package.json or use .mjs extensions) even when your module setting is something like ES2015.

Trying to do this under classic "node" resolution would cause a fuss because Node.js itself would have a problem with it. But the bundler doesn’t care, and now TypeScript won’t either. If you’re using a modern bundler, this is often the best choice as it most closely matches your bundler’s behavior.

// A great config for a project using Vite or Webpack
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2020",
    "strict": true,
    "moduleDetection": "force", // Another cool new option to force file-based modules
    // ... other settings
  }
}

Path Mapping: When You Need to Be the Boss

Sometimes, you need to override the default resolution. Maybe you have deeply nested folders and you’re tired of writing import { thing } from '../../../../utils';. Enter paths.

{
  "compilerOptions": {
    "baseUrl": "./", // Base directory to resolve non-absolute paths from. REQUIRED for paths.
    "paths": {
      "@/*": ["src/*"], // Map `@/components/Button` to `src/components/Button`
      "utils": ["src/common/utils"] // Map `utils` to `src/common/utils`
    }
  }
}

This is a TypeScript feature that lets you create aliases. It’s fantastic for cleaning up your imports. Crucially, this is a compile-time feature. Your bundler or runtime (e.g., Node.js) won’t understand @/ unless you also configure it to (e.g., with a plugin in Webpack or a similar paths setting in tsconfig-paths for Node). TypeScript uses this mapping to find the types; something else needs to use it to find the actual code at runtime.