Module Resolution in TypeScript: node, bundler, and More
AI generated
<T>
type
TypeScript · Module Resolution · Vite · Webpack
Module Resolution in TypeScript: node, bundler, and More
How the compiler actually resolves your import paths

The wrong moduleResolution setting produces confusing cannot find module errors even though the code is actually correct. This article explains how TypeScript resolves import paths under node node16 and bundler why Vite and Webpack follow different rules than the plain tsc compiler and how to match the right strategy to your actual build tool.

13 min read moduleResolution · node16 · bundler tsconfig.json · Vite · Webpack

1. Why "cannot find module" errors happen

Few TypeScript error messages cause as much confusion as Cannot find module './utils' or its corresponding type declarations. The cause is rarely missing code, it is almost always the wrong moduleResolution setting in tsconfig.json. TypeScript strictly separates type checking from actual module resolution: the compiler simulates how an import path would be resolved at runtime, and that simulation follows a different algorithm depending on which strategy is configured.

Pick the wrong strategy and you either get red squiggles under code that actually works fine at runtime, or worse, tsc reports no error at all while the bundler or Node.js crashes when the code actually runs. Since TypeScript 5.0, moduleResolution: "bundler" exists as a third main option alongside the classic node strategy and the strict node16/nodenext strategies. Understanding which strategy mirrors which runtime behavior lets you diagnose most module resolution errors in seconds instead of hours.

2. How Node's CommonJS resolution algorithm works (the classic node strategy)

The setting moduleResolution: "node" (also called node10 in newer TypeScript versions) mirrors the classic CommonJS algorithm that Node.js's require() has always used. For a relative import like ./utils, the algorithm first checks the exact file, then the same file with the extensions .ts, .tsx, and .d.ts, then a file of the same name with .js. If no matching file exists, it checks whether ./utils is a directory containing an index file.

For non relative imports like lodash, the algorithm walks up the directory tree from the current folder step by step, searching every parent node_modules folder until it finds a match or reaches the filesystem root. This behavior is convenient because virtually any import path works without an extension, but it no longer matches the strict ESM resolver that Node.js has used since version 12 for real .mjs files and "type": "module" packages.

3. ESM resolution and the node16/nodenext strategy (package.json exports, mandatory extensions)

With moduleResolution: "node16" or "nodenext", TypeScript mirrors the actual ECMAScript module resolver of Node.js, not the old CommonJS algorithm anymore. The most important practical difference: relative imports require an explicit file extension. An import like ./utils fails, while ./utils.js works, even if the source file is actually named utils.ts. TypeScript deliberately expects the extension that will exist after compilation, not the extension of the source file.

In addition, node16/nodenext respects the exports field in a package's package.json and blocks imports of files that are not explicitly exposed there, even if the file physically exists inside the node_modules folder. The field "type": "module" in package.json also decides per package whether .js files are interpreted as CommonJS or as ESM, something TypeScript tracks for every single file in your project as well.


// tsconfig.json: "moduleResolution": "node16"

// utils.ts
export function formatPrice(cents: number): string {
  return (cents / 100).toFixed(2);
}

// checkout.ts

// WRONG under node16: missing file extension
import { formatPrice } from './utils';
// error TS2835: Relative import paths need explicit file
// extensions in ECMAScript imports when '--moduleResolution' is 'node16'

// RIGHT under node16: extension refers to the compiled output, not the source file
import { formatPrice } from './utils.js';

4. The bundler strategy (designed for Vite, Webpack, esbuild)

The setting moduleResolution: "bundler", introduced in TypeScript 5.0, solves a real problem: neither the old node strategy nor node16/nodenext correctly model how modern bundlers actually resolve modules. Vite, Webpack, and esbuild do not require a file extension for relative TypeScript imports, yet they still respect the exports field of modern packages. The bundler strategy combines exactly these two properties, allowing ./utils without an extension while at the same time understanding modern package.json field conventions.

Important to understand: moduleResolution: "bundler" is meant exclusively for type checking. It tells tsc to behave like your actual build tool, but it does not itself emit JavaScript code for production use when you rely on Vite or Webpack for the real build. In that setup tsc typically runs only with noEmit: true for type checking, while the bundler handles the actual bundling.


// tsconfig.json - for a Vite or Webpack project
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  }
}

// tsconfig.json - for a tsc-only CLI build (Node.js ESM output)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "outDir": "dist",
    "declaration": true
  }
}

5. How paths and baseUrl interact with moduleResolution

The paths field in tsconfig.json is purely a compile time shortcut for the TypeScript compiler, not a real runtime redirect. An entry like "@app/*": ["src/*"] tells tsc where to find the types for @app/utils, but it creates no mechanism that actually resolves this alias at runtime. That is the most common source of a deceptive pattern: the editor shows no errors, tsc compiles cleanly, but Node.js or the test runner throws Cannot find module '@app/utils' when the code actually runs.

Vite and Webpack need their own, separate alias configuration, such as resolve.alias in vite.config.ts, maintained independently of tsconfig.json. For plain Node.js scripts without a bundler, you additionally need a package like tsconfig-paths or tsc-alias that rewrites the aliases into real relative paths after compilation. baseUrl additionally affects which root directory non relative imports without a matching paths entry are searched from, though node16/nodenext now largely ignores it.

6. Matching moduleResolution to your actual build tool

The rule of thumb is simple, yet it is still ignored regularly: moduleResolution should always mirror the tool that actually resolves your modules at runtime, not whichever setting merely sounds the most modern. If you build with Vite, Webpack, or esbuild, "bundler" belongs in your tsconfig.json, because those tools own the resolution and tsc is only responsible for type checking. If instead you run code directly with tsc or ts-node without an additional bundler, the setting must exactly mirror how Node.js itself resolves modules, so node16 or nodenext.

Libraries published as an npm package that are imported by both CommonJS and ESM consumers should also use node16/nodenext, because only this strategy correctly performs the exports field validation required for dual packages. A Vite alias in your vite.config.ts also has no effect on type checking unless tsconfig.json knows the same alias through paths: both configurations must be maintained in sync, otherwise editor experience and actual build behavior drift apart.


// vite.config.ts - runtime alias resolution (this is what actually runs)
import { defineConfig } from 'vite';
import path from 'node:path';

export default defineConfig({
  resolve: {
    alias: {
      '@app': path.resolve(__dirname, 'src'),
    },
  },
});

// tsconfig.json must mirror the same alias for the type checker,
// otherwise the editor and tsc will report false "cannot find module" errors:
// {
//   "compilerOptions": {
//     "moduleResolution": "bundler",
//     "baseUrl": ".",
//     "paths": { "@app/*": ["src/*"] }
//   }
// }

7. Debugging "cannot find module" step by step

The first step with any cannot find module error is to read the exact error message instead of skimming it. TypeScript distinguishes between several related but different error codes: TS2307 (Cannot find module) points to a fundamental resolution problem, TS2835 points to a missing file extension under node16/nodenext, and TS7016 points to a package without type definitions. The --traceResolution flag on a direct tsc invocation prints, for every import path, exactly which candidate paths the compiler checked and at which point the search failed.

The second step is checking module and moduleResolution for consistency: since version 5, TypeScript directly rejects certain inconsistent combinations with a configuration error. The third step concerns node_modules itself: a full reinstall fixes a surprising number of cases where a stale package.json with an incorrect exports field had been cached. Only once all of that is ruled out is it worth hunting for a genuine configuration mistake in your own tsconfig.json.


$ npx tsc --noEmit

src/checkout.ts:3:29 - error TS2307: Cannot find module './utils' or its
corresponding type declarations.

3 import { formatPrice } from './utils';
                              ~~~~~~~~~~

# Enable verbose resolution tracing to see every candidate path tsc checked
$ npx tsc --noEmit --traceResolution | grep -A 2 "Module './utils'"

# Rule out a stale node_modules / exports-field cache
$ rm -rf node_modules package-lock.json && npm install

8. Monorepo and package.json "exports" pitfalls

In monorepos using npm, pnpm, or Yarn workspaces, one package frequently imports another by its package name, such as @internal/shared-ui. As soon as that internal package defines a modern exports field, node16/nodenext blocks every import path that is not explicitly listed there, even if the target file physically exists and would otherwise be reachable through a relative path without any problem. A common mistake: a developer imports @internal/shared-ui/dist/utils directly because it used to work, but after an exports field gets introduced, that path disappears from the package's public API.

The reliable fix is to explicitly declare every deliberately public entry point in the exports field, including separate entries for types, import, and require, so that both node16/nodenext and older consumers resolve correctly. TypeScript project references with composite: true make the problem worse still when the referenced packages use different module settings, because their output formats then stop being compatible with each other.


{
  "name": "@internal/shared-ui",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/styles.css"
  }
}

// Importing a path not listed above fails under node16/nodenext
// even though the file physically exists on disk:
// import { formatPrice } from '@internal/shared-ui/dist/utils';
// error TS2307: Cannot find module '@internal/shared-ui/dist/utils'
// or its corresponding type declarations.

9. Module resolution strategies compared

The table below shows which moduleResolution setting fits which build scenario, and which choice regularly leads to cannot find module errors in practice.

Scenario Wrong choice Recommended moduleResolution Why
tsc-only CLI build (Node.js output) classic / node node16 / nodenext Mirrors the real Node.js ESM resolver, including exports field and mandatory extensions
Vite project node16 bundler Vite allows extensionless imports, node16 wrongly enforces file extensions
Webpack project classic bundler Webpack respects exports and needs no .js extension on TS imports
Node.js ESM library (npm package) node node16 / nodenext Only node16/nodenext correctly validates the exports field for ESM consumers
Node.js CommonJS library (npm package) bundler node / node16 bundler ignores the CommonJS-specific rules that require() actually uses

In practice, the biggest lever is not picking whichever setting sounds theoretically the most modern, but picking the setting that exactly matches the tool that actually resolves your modules at runtime. Getting this table wrong rarely produces an obvious crash, more often it produces silent discrepancies between editor, type checking, and production behavior that only surface with unusual import paths.

Mironsoft

Build tooling, TypeScript configuration, and frontend infrastructure for Magento and headless projects

Ready to solve your TypeScript build errors for good?

We analyze your tsconfig.json, align moduleResolution with your actual build tool, and fix cannot find module errors for good, whether you work with Vite, Webpack, or plain tsc.

tsconfig audit

Analysis of moduleResolution, paths, and exports fields for inconsistencies

Build tool migration

Switching between tsc, Vite, and Webpack without module resolution chaos

Monorepo setup

Clean exports fields and project references for workspaces

10. Summary

The moduleResolution strategies in TypeScript, node, node16/nodenext, and bundler, solve one shared underlying problem: the compiler has to mirror how import paths are actually resolved at runtime, otherwise type checking and production behavior drift apart. The classic node strategy mirrors the old CommonJS algorithm and allows extensionless imports. node16/nodenext mirrors the strict ECMAScript module resolver of Node.js, including mandatory extensions and exports field validation. bundler combines the convenience of extensionless imports with an understanding of modern exports fields, and is the right choice for practically every Vite, Webpack, or esbuild project.

The decisive mistake is rarely a lack of technical knowledge, it is a misconfiguration that does not match the actual build tool: paths without a matching bundler alias, node16 in a Vite project, or bundler in a library that is also imported by plain CommonJS consumers. Consistently coupling moduleResolution to the actual runtime environment and explicitly maintaining exports fields in your own packages eliminates most cannot find module errors for good, instead of debugging them one at a time.

Module Resolution in TypeScript - The Essentials at a Glance

node vs. node16

node mirrors the old CommonJS resolution, node16/nodenext mirrors the real ESM resolver with mandatory extensions and exports validation.

bundler strategy

Introduced in TypeScript 5.0 for Vite, Webpack, and esbuild: extensionless imports plus a modern understanding of the exports field.

paths is not a runtime alias

tsconfig paths only affects type checking. Vite, Webpack, or tsc-alias need their own, separately maintained alias configuration.

Maintain the exports field

Only explicitly listed entry points are importable under node16/nodenext, even if the file physically exists.

11. FAQ: Module Resolution in TypeScript

1What does moduleResolution in tsconfig.json actually do?
Determines which algorithm TypeScript uses to resolve an import path. Only affects type checking, not the actual runtime resolution performed by bundlers or Node.js.
2What is the difference between node and node16?
node mirrors the old CommonJS algorithm and allows extensionless imports. node16 mirrors the strict ESM resolver, with mandatory extensions and exports field checking.
3Why does node16 require a file extension on relative imports?
Because node16 mirrors the real Node.js ESM resolver, which requires extensions. The expected extension is that of the compiled file (.js), not the source file (.ts).
4When should I use moduleResolution: "bundler"?
When Vite, Webpack, or esbuild own the actual resolution and tsc only type checks. Allows extensionless imports and understands modern exports fields.
5Why does my paths alias work in the IDE but not at runtime?
paths is only a compile time shortcut with no runtime effect. Vite, Webpack, or tsc-alias need their own, separately maintained alias configuration.
6What exactly does the error TS2307 Cannot find module mean?
The compiler cannot find a matching file or type declaration for the import path. Usually caused by the wrong moduleResolution, a missing extension under node16, or a blocking exports field.
7Why does the exports field block an import even though the file exists?
Under node16/nodenext, only paths listed in the exports field are importable. The physical existence of the file does not matter if it is not part of the public API.
8Can I just set moduleResolution to "bundler" for every project?
No. Only makes sense when a real bundler owns the resolution. For tsc-only builds or CommonJS libraries, bundler leads to wrong assumptions and runtime errors.
9How do I debug module resolution errors that only happen in CI?
Compare Node.js version and lockfile between local and CI, fully reinstall node_modules in CI, run tsc --traceResolution locally with an identical tsconfig.json.
10What is the difference between node16 and nodenext?
Functionally identical today. node16 is pinned to Node.js 16, nodenext automatically grows with future resolution changes. Usually the more future-proof choice for new projects.