| |

TypeScript 56 ๐Ÿ”ท Module Resolution Strategies

In TypeScript 55, the module syntax was covered โ€” import and export in their value, type-only, and side-effect forms. This chapter is about what happens when the compiler sees an import path: how it finds the file the path refers to. That is module resolution, and it is governed by the moduleResolution compiler option. The mode determines whether extensions are required, whether the exports field in package.json is honored, how node_modules is traversed, how path mappings work, and how conditional exports are selected. Choosing the wrong mode produces errors that are hard to diagnose: a package that resolves in the editor but fails at runtime, a relative import that works under one mode and errors under another, a package.json exports entry that is ignored. This chapter covers each mode, the algorithm each uses, the paths and baseUrl options, the exports and imports fields, and the rules that tie the resolution to the runtime.

Key point: The moduleResolution option determines the resolver. The legacy node (also called node10) mode resolves like Node’s CommonJS: extensionless imports, index.js, package.json main. The node16 and nodenext modes resolve like modern Node: file extensions are required in relative imports, and the exports field in package.json is honored. The bundler mode is designed for code that a bundler will process: extensionless imports are allowed, and the exports field is honored. The classic mode is the original TypeScript resolver and is rarely used. The mode must match the runtime: nodenext for Node, bundler for a bundled app, and node only for legacy compatibility.


What module resolution does

When the compiler sees import { User } from "./user", it must find the file that ./user refers to. The path is relative, and the file could be user.ts, user.tsx, user.d.ts, user/index.ts, or a JavaScript file with a matching declaration. The resolver tries the candidates in order and uses the first that exists.

The resolution inputs. The resolver uses the import path, the importing file’s location, the moduleResolution mode, the paths and baseUrl options, and the package.json files it finds along the way. The combination determines which file is selected.

Why the mode matters. The same import path can resolve to different files under different modes. ./user under node tries user.ts, user.tsx, user.d.ts, and user/index.ts. Under node16, the import must include the extension โ€” ./user.js โ€” because that is what the emitted JavaScript will use. The mode changes the rules, and the rules determine the result.

Why the resolution must match the runtime. The compiler resolves the import to a TypeScript file, but the runtime resolves the same import to a JavaScript file. If the two resolutions disagree โ€” the compiler finds one file and the runtime finds another โ€” the code fails at runtime even though it compiles. The mode is how the compiler is told which runtime to match.

Why the errors are confusing. A resolution error says “Cannot find module ‘./user’ or its corresponding type declarations.” The message does not say which mode is in use or which candidates were tried. The diagnosis requires knowing the mode and the algorithm, which is what this chapter provides.

Why the mode should be set explicitly. If moduleResolution is not set, TypeScript infers it from the module option. module: commonjs implies node, module: nodenext implies nodenext, and so on. The inference is usually correct, but setting the option explicitly makes the intent clear and prevents surprises when the module option changes.

Why the resolution is a compile-time concern. The emitted JavaScript has the same import path as the source, with the extension as the developer wrote it. The runtime resolves the path according to its own rules โ€” Node’s rules for a Node application, the bundler’s rules for a bundled app. The compiler’s job is to resolve the path to a TypeScript file so it can check the types, and to emit the path in a form that the runtime will resolve to the same module. The mode is what aligns the two.


The classic mode

The classic mode is TypeScript’s original resolver. It is rarely used today and exists for historical compatibility.

{ "compilerOptions": { "moduleResolution": "classic" } }

The algorithm walks up the directory tree from the importing file, looking for the module name as a file or a directory. For a relative import, it looks for the file in the current directory and then in the parent directories. It does not understand node_modules in the Node way, and it does not honor the exports field.

Why classic exists. It was the resolver for TypeScript’s early years, before Node’s module system became the dominant model. It is still used in a few legacy projects, but it is not the default for new code.

Why classic is not recommended. It does not match any runtime’s resolution. A project that uses classic compiles, but the emitted code does not resolve correctly under Node or under a bundler. The mode is a legacy artifact, and new projects should not use it.

When classic appears. A tsconfig.json that does not set moduleResolution and sets module: amd or module: system gets classic by inference. A project that has not been updated since the early TypeScript years may still have it. The fix is to set the mode explicitly to match the runtime.


The node mode (node10)

The node mode, also called node10, resolves like Node’s CommonJS. It is the traditional mode and remains common for libraries that target bundlers.

{ "compilerOptions": { "moduleResolution": "node" } }

The algorithm looks for the module in node_modules, starting from the importing file’s directory and walking up. It tries the file with extensions .ts, .tsx, .d.ts, then .js, .jsx, then the directory’s index files, then the package.json‘s main field.

The candidate order for ./user.

  1. ./user.ts
  2. ./user.tsx
  3. ./user.d.ts
  4. ./user/package.json (the main or types field)
  5. ./user/index.ts
  6. ./user/index.tsx
  7. ./user/index.d.ts

The first that exists is used. The extensionless import works because the resolver tries the extensions.

Why node is still common. Many packages are published for bundlers, and the bundler’s resolver is closer to node than to node16. The node mode accepts extensionless imports, which is what most code uses, and it resolves the main field of package.json, which is the traditional entry point.

Why node is not correct for modern Node. Modern Node’s ESM requires file extensions in relative imports and honors the exports field. The node mode does neither. A project that uses node and runs on Node’s ESM resolves differently at runtime than at compile time, and the mismatch produces “module not found” errors at runtime.

Why the exports field is ignored. The exports field is the modern way for a package to declare its entry points. The node mode does not read it; it reads main and types instead. A package that relies on exports โ€” with conditional exports for ESM and CommonJS โ€” is resolved incorrectly under node.

Why node is sometimes called node10. The name node10 was introduced to distinguish it from node16 and nodenext. It refers to the behavior of Node 10, which was the Node version at the time the mode was current. The name is the same resolver as node, and both are accepted.


The node16 and nodenext modes

The node16 and nodenext modes resolve like modern Node’s ESM and CommonJS. They are the correct modes for a project that runs on Node.

{ "compilerOptions": { "moduleResolution": "nodenext" } }

The algorithm honors the exports field in package.json, requires file extensions in relative imports, and distinguishes ESM from CommonJS by the file extension and the "type" field.

The extension requirement. A relative import must include the .js extension, because that is what the emitted JavaScript will use.

// user.ts
export interface User { id: string; }

// app.ts โ€” under node16 or nodenext
import { User } from "./user.js";  // โœ… the .js extension is required
// import { User } from "./user";  // โŒ error: extension required
// import { User } from "./user.ts"; // โŒ error: .ts is not allowed

The .js extension in the source refers to the emitted .js file, not the source .ts file. The compiler resolves ./user.js to ./user.ts at compile time, and the runtime resolves ./user.js to the actual ./user.js at runtime. The two agree.

Why the extension is required. Node’s ESM requires the extension. The TypeScript source must use the same path the runtime will use. The rule is what makes the compile-time and runtime resolutions agree.

Why .ts is not allowed. The emitted file is .js, not .ts. An import with .ts would fail at runtime because no .ts file exists. The compiler rejects the import to prevent the runtime error.

The exports field. The node16 mode reads the exports field in a package’s package.json and uses it to resolve subpath imports.

// node_modules/some-package/package.json
{
  "name": "some-package",
  "exports": {
    ".": {
      "import": "./esm/index.js",
      "require": "./cjs/index.js"
    },
    "./utils": {
      "import": "./esm/utils.js",
      "require": "./cjs/utils.js"
    }
  }
}

An import of some-package resolves to ./esm/index.js under ESM and ./cjs/index.js under CommonJS. An import of some-package/utils resolves to the corresponding file. The exports field is the package’s declared interface, and the mode honors it.

Why the exports field matters. A package that declares exports has a defined public interface. Subpaths not in the exports field are not importable, even if the files exist. This is the mechanism that prevents deep imports into a package’s internals. The node mode ignores this, so a deep import that works under node fails under node16.

Why node16 and nodenext are similar. The node16 mode targets Node 16’s resolution, and nodenext targets the latest Node. The behaviors are nearly identical; the difference is in the details of the exports field and the conditions. For most projects, nodenext is the choice.

Why the mode requires the module option to match. The moduleResolution: nodenext should be paired with module: nodenext. The two work together to emit the correct format and resolve the correct paths. A mismatch โ€” module: esnext with moduleResolution: nodenext โ€” produces inconsistent behavior.


The bundler mode

The bundler mode is designed for code that a bundler will process. It accepts extensionless imports and honors the exports field, matching the behavior of Vite, Webpack, and esbuild.

{ "compilerOptions": { "moduleResolution": "bundler" } }

The mode allows extensionless relative imports, reads the exports field, and supports the imports field for the # prefix. It is the recommended mode for applications built with a bundler.

Why bundler is different from node16. The bundler resolves the extensionless import at build time, so the extension is not required in the source. The node16 mode requires the extension because the runtime does not do the resolution. The difference is in who resolves the path: the bundler at build time, or Node at runtime.

Why bundler is the modern default for applications. Most applications are built with a bundler, and the bundler’s resolution is the one that matters. The bundler mode matches the bundler’s rules, so the compile-time and build-time resolutions agree. The node16 mode would require extensions in every relative import, which is unnecessary for a bundled application.

The imports field. The bundler mode supports the imports field in package.json, which declares internal aliases with a # prefix.

{
  "imports": {
    "#utils/*": "./src/utils/*.ts"
  }
}

An import of #utils/format resolves to ./src/utils/format.ts. The imports field is the package’s internal alias mechanism, and it works without a bundler-specific configuration.

Why bundler does not support the paths field the same way. The paths option is a TypeScript-specific mapping, and the bundler does not read it. The paths must be duplicated in the bundler’s configuration (Vite’s resolve.alias, Webpack’s resolve.alias), or replaced by the imports field, which the bundler reads natively.

Why the mode is paired with module: esnext or module: preserve. The bundler mode emits ESM, and the module option should be esnext or preserve. The preserve option is newer and emits the imports as written without transformation, which matches the bundler’s expectations.

Why the mode is not for libraries. A library is consumed by other projects, which may use different resolvers. A library should use node16 or nodenext so that its declarations resolve correctly for every consumer. The bundler mode is for applications, not libraries.

Why the choice between bundler and nodenext is the runtime. If the code runs through a bundler โ€” a browser app, a Vite app, an esbuild build โ€” use bundler. If the code runs directly on Node โ€” a CLI, a server, a library โ€” use nodenext. The mode is the runtime, and the runtime is the decision.


The paths and baseUrl options

The paths option declares aliases for import paths, and the baseUrl option sets the base for non-relative imports. Together they are the mechanism for a project’s internal aliases.

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

An import of @utils/format resolves to src/utils/format under the baseUrl and the paths mapping. The @ prefix is a convention that distinguishes internal aliases from package imports.

Why paths is useful. A deep relative import like ../../../../utils/format is hard to read and fragile โ€” moving the file breaks the import. An alias like @utils/format is stable and readable. The paths option is the way to declare the aliases.

Why baseUrl is sometimes omitted in modern TypeScript. In TypeScript 4.1 and later, paths works without baseUrl, with the paths resolved relative to the tsconfig.json. The baseUrl is still useful when the aliases should be relative to a specific directory.

Why paths must be duplicated for the runtime. The paths option is TypeScript-specific. The runtime โ€” Node or the bundler โ€” does not read it. The aliases must be declared in the runtime’s configuration: Vite’s resolve.alias, Webpack’s resolve.alias, Jest’s moduleNameMapper, or the imports field in package.json.

// vite.config.ts
import { defineConfig } from 'vite';
import path from 'node:path';

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

The paths in tsconfig.json and the alias in the bundler’s configuration must agree. If they do not, the compiler resolves one path and the bundler resolves another, and the build fails or the runtime errors.

Why the imports field is preferred over paths for new projects. The imports field in package.json is read by both TypeScript and the bundler, so the alias is declared once. The paths option is a TypeScript-only mechanism that requires duplication. For a project that uses a bundler, the imports field is the more maintainable choice.

Why the paths mapping must have the wildcard. The * in @utils/* and utils/* is what makes the mapping work for multiple imports. A mapping without the wildcard is an exact match, which is rarely wanted. The wildcard is the mechanism for a prefix mapping.


The exports and imports fields

The exports and imports fields in package.json are the modern way to declare a package’s interface. They are honored by the node16, nodenext, and bundler modes, and ignored by the node mode.

The exports field. Declares the package’s public entry points.

{
  "name": "my-package",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  }
}

The . entry is the package’s main entry point, and the ./utils entry is a subpath. Each entry has conditions: types for the TypeScript declaration, import for ESM, require for CommonJS. The resolver picks the condition that matches the mode and the consumer.

Why the conditions matter. A package can provide different files for ESM and CommonJS, and the resolver picks the one that matches the consumer. The types condition provides the declaration file, which is what TypeScript uses. The import and require conditions provide the runtime files for each module system.

Why the types condition must come first. The conditions are checked in order, and the first match wins. The types condition must be first so that TypeScript picks it. If import comes first, TypeScript may resolve to the .mjs file and fail to find the declarations.

The imports field. Declares internal aliases with a # prefix.

{
  "imports": {
    "#internal/*": "./src/internal/*.ts",
    "#config": "./src/config.ts"
  }
}

An import of #internal/helpers resolves to ./src/internal/helpers.ts. The # prefix distinguishes the internal aliases from package imports, and it is reserved for this purpose.

Why the imports field is for internal use. The imports field declares aliases that are used within the package, not by its consumers. The exports field declares what consumers can import. The two are separate, and the distinction is what makes the package’s interface explicit.

Why the fields are read only in the modern modes. The node mode reads main and types, not exports. A package that uses exports is resolved incorrectly under node. The modes that honor the fields are node16, nodenext, and bundler, which are the modes for modern code.

Why the exports field can break existing code. A package that adds an exports field restricts its public interface to the declared entry points. A deep import like some-package/internal/helper that worked before the field was added fails afterward, because the subpath is not declared. The field is the package’s way of enforcing its interface, and the restriction is intentional. The fix for the consumer is to use the declared entry points.


Complete Example Session

// ============================================
// PART 1: RELATIVE IMPORT UNDER node16
// ============================================

// user.ts
export interface User { id: string; name: string; }

// app.ts
import type { User } from "./user.js";  // โœ… the .js extension
// import type { User } from "./user";  // โŒ extension required
// import type { User } from "./user.ts"; // โŒ .ts not allowed

// ============================================
// PART 2: RELATIVE IMPORT UNDER bundler
// ============================================

// app.ts
import type { User } from "./user";  // โœ… extensionless is fine

// ============================================
// PART 3: PACKAGE IMPORT
// ============================================

import { z } from "zod";
// Resolved through node_modules/zod
// The exports field declares the entry points

// ============================================
// PART 4: THE exports FIELD
// ============================================

// node_modules/some-package/package.json
// {
//   "name": "some-package",
//   "exports": {
//     ".": {
//       "types": "./dist/index.d.ts",
//       "import": "./dist/index.mjs",
//       "require": "./dist/index.cjs"
//     },
//     "./utils": {
//       "types": "./dist/utils.d.ts",
//       "import": "./dist/utils.mjs",
//       "require": "./dist/utils.cjs"
//     }
//   }
// }

import { main } from "some-package";        // โœ… the "." entry
import { helper } from "some-package/utils"; // โœ… the "./utils" entry
// import { internal } from "some-package/internal"; // โŒ not declared

// ============================================
// PART 5: THE imports FIELD
// ============================================

// package.json
// {
//   "imports": {
//     "#utils/*": "./src/utils/*.ts",
//     "#config": "./src/config.ts"
//   }
// }

import { format } from "#utils/format";  // โœ…
import { config } from "#config";        // โœ…

// ============================================
// PART 6: THE paths OPTION
// ============================================

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

import { format } from "@utils/format";  // โœ… resolved to src/utils/format

// The bundler must declare the same alias.

// ============================================
// PART 7: THE vite.config.ts ALIAS
// ============================================

// vite.config.ts
// import { defineConfig } from 'vite';
// import path from 'node:path';
//
// export default defineConfig({
//   resolve: {
//     alias: {
//       '@app': path.resolve(__dirname, 'src/app'),
//       '@utils': path.resolve(__dirname, 'src/utils'),
//     },
//   },
// });

// ============================================
// PART 8: TSCONFIG FOR A NODE LIBRARY
// ============================================

// {
//   "compilerOptions": {
//     "target": "es2022",
//     "module": "nodenext",
//     "moduleResolution": "nodenext",
//     "verbatimModuleSyntax": true,
//     "declaration": true,
//     "outDir": "./dist",
//     "strict": true
//   }
// }

// ============================================
// PART 9: TSCONFIG FOR A BUNDLED APP
// ============================================

// {
//   "compilerOptions": {
//     "target": "es2022",
//     "module": "esnext",
//     "moduleResolution": "bundler",
//     "verbatimModuleSyntax": true,
//     "isolatedModules": true,
//     "paths": {
//       "@app/*": ["./src/app/*"]
//     },
//     "strict": true
//   }
// }

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't use `node` for a modern Node project
// The exports field is ignored.

// Don't use `bundler` for a library
// The declarations do not resolve for consumers.

// Don't forget the .js extension under node16
// The runtime cannot resolve the path.

// Don't write .ts in an import
// The emitted file is .js.

// Don't declare a path alias in tsconfig only
// The bundler must declare the same alias.

// Don't mix moduleResolution and module
// The two must agree.

// Don't rely on deep imports into a package
// The exports field may forbid them.

The ten parts cover the extension rules, the exports and imports fields, the paths option, the bundler alias, the two tsconfig.json configurations, and the anti-patterns.


Quick Reference

The Modes

ModeExtensionsexportsFor
classicOptionalNoLegacy TypeScript
node / node10OptionalNoLegacy bundlers
node16RequiredYesNode 16
nodenextRequiredYesModern Node
bundlerOptionalYesBundled apps

Extension Rules

Mode./user./user.js./user.ts
nodeโœ…โœ…โŒ
node16 / nodenextโŒโœ…โŒ
bundlerโœ…โœ…โŒ

The exports Field Conditions

ConditionFor
typesTypeScript declarations
importESM
requireCommonJS
defaultFallback

The paths and baseUrl

OptionPurpose
baseUrlBase directory for non-relative imports
pathsAlias mappings
paths without baseUrlResolved relative to tsconfig.json
Bundler aliasThe runtime equivalent

Configuration Pairs

ProjectmodulemoduleResolution
Node librarynodenextnodenext
Bundled appesnextbundler
Legacy Nodecommonjsnode
Bundled libraryesnextbundler (with declaration)

Common Errors

ErrorCause
“Cannot find module ‘./user'”Extension missing under node16
“Cannot find module ‘pkg/sub'”Subpath not in exports
“The current file is a CommonJS module”ESM import in a CJS file
“Cannot find name ‘require'”CJS code in an ESM file

Best Practices

โœ… Do This:

// Use .js extensions under node16 and nodenext
import type { User } from "./user.js";                         // โœ…

// Use extensionless under bundler
import type { User } from "./user";                            // โœ…

// Declare path aliases in both tsconfig and the bundler
// tsconfig: paths; vite: resolve.alias                        // โœ…

// Use the imports field for internal aliases
// package.json: "imports": { "#utils/*": "./src/utils/*.ts" } // โœ…

// Use the exports field for a library's public interface
// "exports": { ".": { "types": ..., "import": ..., "require": ... } } // โœ…

// Match module and moduleResolution
{ "module": "nodenext", "moduleResolution": "nodenext" }       // โœ…

// Use bundler for applications
{ "moduleResolution": "bundler" }                              // โœ…

โŒ Don’t Do This:

// Don't write .ts in an import
import { User } from "./user.ts";  // โŒ                           // โš ๏ธ

// Don't omit the extension under node16
import { User } from "./user";  // โŒ                              // โš ๏ธ

// Don't use node for a modern Node project
{ "moduleResolution": "node" }  // ignores exports               // โš ๏ธ

// Don't use bundler for a library
{ "moduleResolution": "bundler" }  // declarations break          // โš ๏ธ

// Don't declare aliases in tsconfig only
// The bundler must know the aliases too                         // โš ๏ธ

// Don't rely on deep imports into a package
// The exports field may forbid them                             // โš ๏ธ

// Don't mix module and moduleResolution
{ "module": "esnext", "moduleResolution": "nodenext" }          // โš ๏ธ

Common Pitfalls

PitfallProblemSolution
Missing .js under node16Cannot find moduleAdd the extension
.ts extension in importCompile errorUse .js
node ignores exportsWrong resolutionUse node16/bundler
bundler for a libraryDeclarations breakUse nodenext
Aliases in tsconfig onlyRuntime cannot resolveDuplicate in bundler
types condition not firstTypeScript picks importReorder the conditions
Deep import blockedSubpath not declaredUse the entry points
Mismatched module and modeInconsistent behaviorPair them correctly

Real-World Examples

1. node16 relative import

import type { User } from "./user.js";

2. bundler relative import

import type { User } from "./user";

3. Package import

import { z } from "zod";

4. Subpath import

import { helper } from "some-package/utils";

5. Internal alias with #

import { format } from "#utils/format";

6. Path alias

import { format } from "@utils/format";

7. Bundler alias

// vite.config.ts: resolve.alias

8. Node library tsconfig

{ "module": "nodenext", "moduleResolution": "nodenext" }

9. Bundled app tsconfig

{ "module": "esnext", "moduleResolution": "bundler" }

10. The exports field

{ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs" } }

Visual: The Modes

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  classic                                                 โ”‚
โ”‚    Original TypeScript. Rarely used.                     โ”‚
โ”‚                                                          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  node (node10)                                           โ”‚
โ”‚    Node CommonJS. Extensionless. main field.             โ”‚
โ”‚    Ignores exports. For legacy.                          โ”‚
โ”‚                                                          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  node16 / nodenext                                       โ”‚
โ”‚    Modern Node. Extensions required. exports honored.    โ”‚
โ”‚    For Node applications and libraries.                  โ”‚
โ”‚                                                          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  bundler                                                 โ”‚
โ”‚    Extensionless. exports honored. imports honored.      โ”‚
โ”‚    For applications built with a bundler.                โ”‚
โ”‚                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Extension Rules

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  node16 / nodenext                                       โ”‚
โ”‚                                                          โ”‚
โ”‚  import { x } from "./mod.js"    โœ…                      โ”‚
โ”‚  import { x } from "./mod"       โŒ                      โ”‚
โ”‚  import { x } from "./mod.ts"    โŒ                      โ”‚
โ”‚                                                          โ”‚
โ”‚  The source uses .js because the emitted file is .js.    โ”‚
โ”‚                                                          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  bundler                                                 โ”‚
โ”‚                                                          โ”‚
โ”‚  import { x } from "./mod"       โœ…                      โ”‚
โ”‚  import { x } from "./mod.js"    โœ…                      โ”‚
โ”‚  import { x } from "./mod.ts"    โŒ                      โ”‚
โ”‚                                                          โ”‚
โ”‚  The bundler resolves the extension at build time.       โ”‚
โ”‚                                                          โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  node (node10)                                           โ”‚
โ”‚                                                          โ”‚
โ”‚  import { x } from "./mod"       โœ…                      โ”‚
โ”‚  import { x } from "./mod.js"    โœ…                      โ”‚
โ”‚                                                          โ”‚
โ”‚  The resolver tries the extensions.                      โ”‚
โ”‚                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: The exports Field

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  node_modules/some-package/package.json                  โ”‚
โ”‚                                                          โ”‚
โ”‚  {                                                       โ”‚
โ”‚    "exports": {                                          โ”‚
โ”‚      ".": {                                              โ”‚
โ”‚        "types":   "./dist/index.d.ts",                   โ”‚
โ”‚        "import":  "./dist/index.mjs",                    โ”‚
โ”‚        "require": "./dist/index.cjs"                     โ”‚
โ”‚      },                                                  โ”‚
โ”‚      "./utils": {                                        โ”‚
โ”‚        "types":   "./dist/utils.d.ts",                   โ”‚
โ”‚        "import":  "./dist/utils.mjs",                    โ”‚
โ”‚        "require": "./dist/utils.cjs"                     โ”‚
โ”‚      }                                                   โ”‚
โ”‚    }                                                     โ”‚
โ”‚  }                                                       โ”‚
โ”‚                                                          โ”‚
โ”‚  import from "some-package"        โ†’ the "." entry       โ”‚
โ”‚  import from "some-package/utils"  โ†’ the "./utils" entry โ”‚
โ”‚  import from "some-package/internal" โ†’ โŒ not declared   โ”‚
โ”‚                                                          โ”‚
โ”‚  The exports field is the package's interface.           โ”‚
โ”‚                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: The imports Field

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  package.json                                            โ”‚
โ”‚                                                          โ”‚
โ”‚  {                                                       โ”‚
โ”‚    "imports": {                                          โ”‚
โ”‚      "#utils/*": "./src/utils/*.ts",                     โ”‚
โ”‚      "#config":  "./src/config.ts"                       โ”‚
โ”‚    }                                                     โ”‚
โ”‚  }                                                       โ”‚
โ”‚                                                          โ”‚
โ”‚  import { format } from "#utils/format"                  โ”‚
โ”‚    โ†’ ./src/utils/format.ts                               โ”‚
โ”‚                                                          โ”‚
โ”‚  import { config } from "#config"                        โ”‚
โ”‚    โ†’ ./src/config.ts                                     โ”‚
โ”‚                                                          โ”‚
โ”‚  The # prefix is reserved for internal aliases.          โ”‚
โ”‚  Read by TypeScript and the bundler.                     โ”‚
โ”‚                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: The paths Option

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  tsconfig.json                                           โ”‚
โ”‚                                                          โ”‚
โ”‚  {                                                       โ”‚
โ”‚    "compilerOptions": {                                  โ”‚
โ”‚      "baseUrl": "./src",                                 โ”‚
โ”‚      "paths": {                                          โ”‚
โ”‚        "@utils/*": ["utils/*"]                           โ”‚
โ”‚      }                                                   โ”‚
โ”‚    }                                                     โ”‚
โ”‚  }                                                       โ”‚โ”‚                                                          โ”‚
โ”‚  import { format } from "@utils/format"                  โ”‚
โ”‚    โ†’ src/utils/format.ts                                 โ”‚
โ”‚                                                          โ”‚
โ”‚  The bundler must declare the same alias:                โ”‚
โ”‚    resolve.alias { "@utils": "src/utils" }               โ”‚
โ”‚                                                          โ”‚
โ”‚  Without the duplication, the bundler fails.             โ”‚
โ”‚                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Choosing the Mode

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Where does the code run?                                โ”‚
โ”‚       โ”‚                                                  โ”‚
โ”‚       โ”œโ”€โ”€ Node directly                                  โ”‚
โ”‚       โ”‚      โ””โ”€โ”€ node16 / nodenext                       โ”‚
โ”‚       โ”‚                                                  โ”‚
โ”‚       โ”œโ”€โ”€ A bundler (Vite, Webpack, esbuild)             โ”‚
โ”‚       โ”‚      โ””โ”€โ”€ bundler                                 โ”‚
โ”‚       โ”‚                                                  โ”‚
โ”‚       โ”œโ”€โ”€ A library consumed by others                   โ”‚
โ”‚       โ”‚      โ””โ”€โ”€ node16 / nodenext                       โ”‚
โ”‚       โ”‚                                                  โ”‚
โ”‚       โ””โ”€โ”€ Legacy Node or a bundler                       โ”‚
โ”‚              โ””โ”€โ”€ node (node10)                            โ”‚
โ”‚                                                          โ”‚
โ”‚  The mode must match the runtime.                        โ”‚
โ”‚                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ModeExtensionsexportsFor
classicOptionalNoLegacy
nodeOptionalNoLegacy Node/bundlers
node16RequiredYesNode 16
nodenextRequiredYesModern Node
bundlerOptionalYesBundled apps
OptionPurpose
baseUrlBase for non-relative imports
pathsAlias mappings
exportsPackage public interface
importsInternal aliases (#)
resolve.aliasBundler alias

Key takeaways:

  • Module resolution is the algorithm that finds the file an import path refers to โ€” the moduleResolution mode selects the algorithm
  • node (node10) resolves like Node’s CommonJS โ€” extensionless imports, main field, exports ignored โ€” and is for legacy compatibility
  • node16 and nodenext resolve like modern Node โ€” file extensions are required in relative imports, and the exports field is honored
  • bundler resolves like a bundler โ€” extensionless imports are allowed, and the exports and imports fields are honored
  • The mode must match the runtime โ€” nodenext for Node, bundler for a bundled app, node only for legacy
  • The .js extension is required under node16 and nodenext โ€” the source uses the same path the emitted JavaScript will use
  • The exports field declares a package’s public interface โ€” the conditions types, import, and require are checked in order, and types must come first
  • The imports field declares internal aliases with a # prefix, and it is read by both TypeScript and the bundler
  • The paths option is TypeScript-only โ€” the aliases must be duplicated in the bundler’s configuration, or replaced by the imports field
  • The mode and the module option must agree โ€” nodenext with nodenext, esnext with bundler โ€” and a mismatch produces inconsistent behavior

Remember: Module resolution is the bridge between the import path in the source and the file the compiler and the runtime use. The mode selects the algorithm, and the algorithm must match the runtime. Use nodenext for Node, bundler for a bundled app, and node only for legacy. Require extensions under node16, honor the exports field in modern modes, and duplicate the path aliases for the bundler. The rules are what make the compile-time and runtime resolutions agree.


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!