Skip to main content
Knowledge Hub

Module Resolution in Depth

How TypeScript, Node.js, and the filesystem cooperate to find your imports

Last updated: June 2, 2025

Module resolution is one of the most misunderstood parts of TypeScript and Node.js. Yet it is one of the most critical systems to understand because every import in your code depends on it, development versus production behavior differs dramatically, ts-node and Node.js use different resolution strategies, path aliasing breaks without correct configuration, and many real-world failures originate here.

To be a senior backend engineer, you must know how modules are discovered, located, rewritten, loaded, cached, and executed. This document explains the entire system from first principles.

Why Module Resolution Matters

Every time you write an import statement, you trigger a multi-layered process involving the TypeScript compiler, ts-node, the Node.js module loader, filesystem lookups, package.json metadata, tsconfig path aliasing, and JavaScript module formats.

A mistake in this chain results in cannot find module errors, development and production inconsistencies, broken path aliases, TypeORM migration failures, and build-time errors. Understanding module resolution is essential for predictable architecture.

Mental Model: Module Resolution as a Search Algorithm

When you import a module, the system performs a deterministic search: determine the base directory, apply resolution rules, generate possible file paths, test them in order, and load the first match.

Different environments have different rules. TypeScript uses the TS compiler resolver. ts-node uses TS resolver plus Node resolver. Node.js uses Node resolver for CommonJS or ESM. Bundlers use custom fast resolvers.

Most bugs exist because development uses ts-node, while production uses Node.js, and they do not interpret imports the same way.

The Three Worlds of Module Resolution

Module resolution operates across three separate resolution engines: TypeScript resolver used in development and type-checking, ts-node resolver used in development execution, and Node.js resolver used in production. Your import must satisfy all three if you want stable behavior.

TypeScript Module Resolution

TypeScript supports two main strategies. Classic is the old resolution model, typically used for .d.ts ambient modules, almost never recommended for modern backend apps.

Node is the default and recommended strategy. It mirrors Node.js behavior with additional TypeScript features like .ts and .tsx support, .d.ts type-only modules, path aliasing via paths, and baseUrl resolution. Almost every project uses moduleResolution node implicitly.

How TypeScript Resolves Imports

For relative imports, TypeScript checks .ts, .tsx, .d.ts, and index files. For package imports, TypeScript checks node_modules for package.json types or typings field, index.d.ts, and @types packages.

TypeScript always prefers types first, not JavaScript files. This is crucial for IDEs, tooling, and compiler correctness.

Node.js Module Resolution

Node resolves modules differently based on module type. For CommonJS, Node tries .js, .json, .node, and index files. For ESM, Node uses the ESM loader, which is stricter: file extensions are required, directory imports often require package.json with module or exports field, and .ts is not allowed unless using a loader.

This is why imports that work in TypeScript will break in Node unless you compile correctly.

ts-node Resolution

ts-node is a hybrid resolver. It uses the TS compiler API to find .ts or .tsx files, then uses Node rules for .js, and supports path alias translation via tsconfig-paths/register.

Without tsconfig-paths, imports with aliases will fail at runtime, even if TypeScript accepts them. This is the most common backend error.

Path Aliases

Given baseUrl and paths configuration, TypeScript works and understands aliases, IDE auto-import works, and type checking works. However, ts-node fails unless you include tsconfig-paths/register, and Node.js production completely fails because Node knows nothing about TypeScript paths.

Solutions include using a bundler to rewrite paths, using tsc-alias to rewrite paths after compilation, or avoiding aliases in production code unless bundled.

Module Resolution Failures in Real Systems

Path alias works in VSCode but not in runtime because VSCode uses TS resolver while Node does not.

Code compiles but crashes in production because TypeScript emitted a JavaScript path that doesn’t exist due to folder restructuring.

TypeORM migrations break because migrations use JavaScript files, but TypeScript paths don’t rewrite.

ESM and CommonJS mismatches occur because of module type mismatch between tsconfig and package.json.

ts-node working but tsc builds failing because ts-node loads .ts files directly while Node uses .js.

Module Resolution Is a Layered System

Import Statement: import UserService from "@services/UserService"

┌─────────────────────────┐
│ TypeScript Resolver      │ ← Checks .ts, .tsx, .d.ts files
│ (Development)            │
└────────┬─────────────────┘

┌─────────────────────────┐
│ ts-node Resolver         │ ← Runtime resolution for .ts files
│ (Development Runtime)    │
└────────┬─────────────────┘

┌─────────────────────────┐
│ Node.js Resolver         │ ← Checks .js, .json, .node files
│ (Production)            │
└────────┬─────────────────┘

┌─────────────────────────┐
│ Filesystem Lookup        │ ← Actual file system access
└─────────────────────────┘

Each layer has different rules, different supported extensions, and different metadata interpretation. A senior engineer must know which layer is failing.

Example: Resolution Differences

TypeScript Resolution:

// TypeScript finds this
import { User } from './user'; // Checks: user.ts, user.tsx, user.d.ts

Node.js Resolution:

// Node.js finds this
const { User } = require('./user'); // Checks: user.js, user.json, user.node

Path Alias Resolution:

// tsconfig.json
{
  "paths": {
    "@services/*": ["src/services/*"]
  }
}

// Works in TypeScript, fails in Node.js without tooling
import UserService from "@services/UserService";

Steps to Guarantee Stable Module Resolution

Keep moduleResolution node always. Avoid path aliases unless you use rewriting tools or a bundler. Align your module and type config. Remember that TypeScript paths do not apply to emitted JavaScript. Use tsc-alias or bundlers if needed. Always test both development and production. ts-node is not your production environment.

The Senior Mental Model

Module resolution is a multi-phase search algorithm where TypeScript, Node.js, and ts-node all apply different rules. Stability comes from aligning these rules across environments.

When you understand this, you can avoid runtime crashes, path alias failures, build inconsistencies, ESM and CommonJS chaos, and migration issues. This is a core engineering skill.