Module Resolution in TypeScript
Module resolution is how TypeScript finds the file behind an import path. Learn how bundler, nodenext, and node16 resolution work, and how to pick the right one.
Module resolution is the process TypeScript uses to find the file behind an import path. When you write an import statement, TypeScript must determine which file on disk that string refers to and then check its types.
This is not a TypeScript invention. Every JavaScript runtime and bundler has its own module resolution algorithm, and Node.js resolves modules differently than Webpack, which resolves differently than Vite.
TypeScript's job is to model the resolution behavior of whatever system will ultimately run your code.
The diagram shows the two main resolution paths. With bundler mode, TypeScript tries multiple extensions and fallbacks, matching the flexible resolution most bundlers use. With nodenext, it enforces Node.js's stricter rules.
The Four Resolution Modes
TypeScript supports four moduleResolution settings. Three are useful for modern projects. The fourth is legacy.
| Setting | Use case | Extensionless imports | package.json exports |
|---|---|---|---|
| bundler | Vite, Webpack, Turbopack, tsx | Yes | Yes |
| nodenext | Node.js ESM and CommonJS | Only in require | Yes |
| node16 | Libraries for maximum compatibility | Only in require | Yes |
| node10 | Legacy Node.js, avoid for new code | Yes | No |
The bundler mode is the most permissive and the right choice for most frontend projects.
The nodenext mode enforces Node.js behavior. ECMAScript import statements require full file extensions. CommonJS require calls allow extensionless paths and directory modules.
For the fundamentals of writing imports and exports, see ES modules in TypeScript. The resolution rules build on top of the basic module syntax.
How Extension Substitution Works
The most counterintuitive part of module resolution is that TypeScript resolves .js extensions to .ts files. When you write an import with a .js extension, the compiler does not look for a .js file. It looks for a .ts file first.
The following import triggers a series of lookups:
import { add } from "./math.js";TypeScript tries these paths in order: first the .ts source file, then .tsx if JSX is enabled, then the .d.ts declaration file, and finally the .js file as a last resort.
This behavior exists because TypeScript models the runtime. At runtime, Node.js loads math.js, and TypeScript validates that the types match by resolving that same path to the .ts source instead.
This also means you should never write .ts extensions in your imports. TypeScript prevents this because the compiled JavaScript would contain a .ts extension that no runtime can resolve. Write .js extensions and let the compiler perform the substitution.
Node Modules Package Lookups
When you import a bare specifier like a library name, TypeScript performs a node_modules package lookup. It walks up the directory tree from the importing file, checking each ancestor's node_modules folder for a matching package.
Once a package directory is found, TypeScript looks for type information using a specific priority order. It checks the types field in package.json first, then the exports field if supported by your resolution mode, then a .d.ts file at the package root, and finally the main field with extension substitution applied.
The exports field is supported in bundler, nodenext, and node16 modes. When present, it blocks access to any subpath not explicitly listed. This is why some packages require specific import paths and reject direct paths into their internal directory structure.
Picking the Right Setting
The decision depends on where your code runs. For a frontend project bundled with Vite or Webpack, use bundler. It supports the flexible resolution that bundlers use.
For a Node.js project where TypeScript compiles to JavaScript that Node.js runs directly, use nodenext. It enforces Node.js's module rules and prevents you from writing imports that would crash at runtime.
For a library published to npm, use node16. It applies the strictest rules and library code that passes node16 resolution almost always works with bundler resolution too.
Common Mistakes
Mixing moduleResolution bundler with module commonjs. This combination does not model any real runtime and creates a mismatch between types and runtime values.
Writing .ts extensions in imports. TypeScript rejects this. The compiled JavaScript would contain .ts extensions that cannot be resolved at runtime.
Forgetting .js extensions with nodenext. Node.js ESM requires file extensions. An import without the .js extension fails both at compile time and at runtime. Always include the extension.
Relying on node10 for new projects. Node10 models pre-v12 Node.js behavior and does not support package.json exports. Modern packages that use exports will not resolve correctly. If you are setting up path aliases alongside module resolution, see path aliases in TypeScript.
Rune AI
Key Insights
- Module resolution controls how TypeScript finds the file behind each import path.
- Use bundler for projects using Vite or Webpack. Use nodenext for Node.js.
- TypeScript resolves .js extensions to .ts source files via extension substitution.
- The module and moduleResolution settings must be compatible.
- Module resolution models the host runtime. Pick the setting that matches where your code runs.
Frequently Asked Questions
What is the difference between module and moduleResolution?
Which moduleResolution should I use for a Vite or Webpack project?
Why does TypeScript make me write .js extensions in imports with nodenext?
Conclusion
Module resolution is the bridge between the import paths you write and the files TypeScript checks. Pick bundler for projects bundled with Vite or Webpack. Pick nodenext for Node.js projects. The right setting ensures TypeScript catches import errors at compile time instead of discovering them at runtime.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.