Module Resolution

Difficulty: Advanced

Module resolution is the process TypeScript uses to figure out what a given import statement refers to. When you write `import { something } from './utils'`, TypeScript needs to determine which file contains that export. The resolution strategy varies depending on the `moduleResolution` setting in your tsconfig.json, and understanding these strategies is critical for configuring projects correctly.

The classic resolution strategy mimics how the original TypeScript compiler resolved modules - it searches for .ts, .tsx, and .d.ts files by walking up the directory tree. This is rarely used in modern projects. The node resolution strategy (moduleResolution: 'node') follows Node.js's CommonJS resolution algorithm: it checks for the file directly, then looks for an index file in a directory, and for node_modules packages it reads the 'main' field in package.json.

Modern TypeScript (5.0+) introduced 'node16', 'nodenext', and 'bundler' resolution modes. The 'node16' and 'nodenext' modes support Node.js's native ESM resolution, which requires explicit file extensions in relative imports. The 'bundler' mode is designed for projects using a bundler like Webpack, Vite, or esbuild - it allows extensionless imports while supporting the 'exports' field in package.json, giving you the best of both worlds.

The `baseUrl` and `paths` options in tsconfig.json let you define custom module path aliases. Setting `baseUrl` to '.' or 'src' allows non-relative imports to resolve from that directory. The `paths` option maps patterns to file locations, enabling shortcuts like '@components/*' mapping to 'src/components/*'. Note that `paths` only affects TypeScript's type checking - your bundler or runtime needs equivalent configuration (like Vite's resolve.alias) to actually resolve these paths.

Code examples

Module Resolution Strategies

// tsconfig.json examples for different resolution modes

// For Node.js CommonJS projects:
// { "compilerOptions": { "moduleResolution": "node" } }
// import { helper } from './utils';  // resolves ./utils.ts or ./utils/index.ts

// For Node.js ESM projects:
// { "compilerOptions": { "moduleResolution": "nodenext" } }
// import { helper } from './utils.js';  // must include .js extension

// For bundler-based projects (Vite, Webpack):
// { "compilerOptions": { "moduleResolution": "bundler" } }
// import { helper } from './utils';  // extensionless, supports "exports" field

// Demonstration of resolution concept
function resolveModule(specifier: string, strategy: string): string {
  if (strategy === 'node') {
    return `${specifier}.ts -> ${specifier}/index.ts -> node_modules/${specifier}`;
  }
  if (strategy === 'nodenext') {
    return `${specifier} (must have explicit .js extension)`;
  }
  return `${specifier} (extensionless, checks exports field)`;
}

console.log(resolveModule('./utils', 'node'));
console.log(resolveModule('./utils.js', 'nodenext'));
console.log(resolveModule('./utils', 'bundler'));

Different module resolution strategies determine how TypeScript locates the file for a given import specifier. Choose the strategy that matches your runtime environment.

Path Aliases with baseUrl and paths

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

// With the above config, these imports resolve correctly:
// import { User } from '@models/user';   -> src/models/user.ts
// import { formatDate } from '@utils/date'; -> src/utils/date.ts
// import config from '@config';           -> src/config/index.ts

// Simulating alias resolution
const aliases: Record<string, string> = {
  '@models/*': 'src/models/*',
  '@utils/*': 'src/utils/*',
  '@config': 'src/config/index'
};

function resolveAlias(importPath: string): string {
  for (const [pattern, target] of Object.entries(aliases)) {
    const prefix = pattern.replace('/*', '');
    if (importPath.startsWith(prefix)) {
      return importPath.replace(prefix, target.replace('/*', ''));
    }
  }
  return importPath;
}

console.log(resolveAlias('@models/user'));
console.log(resolveAlias('@utils/date'));
console.log(resolveAlias('@config'));

Path aliases defined in tsconfig.json map short import specifiers to actual file paths. The wildcard (*) in paths captures the rest of the path. Remember that your bundler needs matching configuration.

Key points

Concepts covered

module resolution, Node resolution, bundler resolution, paths, baseUrl, moduleResolution