TypeScript Module Resolution
Every time you write import { thing } from "somewhere", the TypeScript compiler has to answer a question before it can check a single type: which actual file, on disk or in a package, does "somewhere" point to? That process is called module resolution. Get it wrong and you’ll see the infamous Cannot find module error even though your code looks perfectly fine — because the mistake isn’t in your logic, it’s in how the specifier string gets mapped to a file.
Module resolution matters more in TypeScript than in plain JavaScript because TypeScript needs to find not just a runtime value, but also the type information that describes it. Two separate resolution processes are involved in every real project: TypeScript’s own compile-time resolution (used for type-checking), and the JavaScript runtime’s resolution (Node, a browser, or a bundler), which happens later and independently. Understanding both — and where they can disagree — is the difference between code that merely type-checks and code that actually runs.
Overview: How Module Resolution Works
When the compiler encounters an import specifier, the first thing it does is classify it as relative or non-relative:
- Relative specifiers start with
./or../, such asimport { helper } from "./helpers". These are resolved relative to the importing file’s own directory, exactly like a file-system path. - Non-relative specifiers are everything else — bare names like
"lodash", scoped packages like"@scope/pkg", or path aliases like"@app/utils". These are resolved by searchingnode_modulesfolders (or by consulting apathsmapping you configured yourself).
Which exact algorithm TypeScript uses for either case is controlled by the moduleResolution compiler option. Historically there was only one modern strategy (informally called node or Node10), which mimics the classic CommonJS require() algorithm Node.js has used for years. Newer TypeScript adds node16 / nodenext, which understand the difference between ECMAScript modules and CommonJS inside a single Node project (via the package.json "type" field and the "exports" map), and bundler, designed for tools like Vite, esbuild, and webpack that resolve packages themselves but don’t want Node’s stricter ESM rules. There is also a legacy classic strategy, kept only for backwards compatibility with very old TypeScript projects — you should never choose it for new code.
A crucial fact to internalize: module resolution is a compile-time, type-checking concern only. Interfaces, type aliases, and other type-only constructs used to describe a module are completely erased when TypeScript emits JavaScript — there is no runtime trace of them at all. The emitted import/require statement still contains the same specifier string you wrote, and it is Node, the browser, or your bundler that resolves that string again, using its own independent rules, when your code actually runs. TypeScript’s resolution and the runtime’s resolution can and do disagree — that mismatch is the source of most “it compiled but crashed” bugs discussed further down.
Syntax
Module resolution behavior is configured entirely through tsconfig.json compiler options. The most relevant ones:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": ".",
"paths": {
"@app/*": ["src/*"]
},
"resolveJsonModule": true,
"esModuleInterop": true
}
}
| Option | Purpose |
|---|---|
module |
What module system TypeScript emits (CommonJS, ESNext, NodeNext, Preserve, …). Closely tied to moduleResolution. |
moduleResolution |
Which file-lookup algorithm the compiler uses: classic, node10, node16, nodenext, or bundler. |
baseUrl |
A root directory non-relative imports can be resolved against, before falling back to node_modules. |
paths |
Maps alias patterns (e.g. @app/*) to real locations under baseUrl, purely for the type-checker. |
resolveJsonModule |
Allows import data from "./data.json" to type-check, inferring a type from the JSON shape. |
esModuleInterop |
Smooths over default-import interop with CommonJS packages that use module.exports = .... |
allowJs |
Lets the resolver also consider .js/.jsx files as valid module targets. |
Examples
Example 1: a file only becomes a module because it has an import or export. Module resolution only applies to files that participate in the module system in the first place. A .ts file with no top-level import or export is treated as a global script, not a module — its declarations leak into the global scope instead of being resolved through any specifier.
export interface Counter {
value: number;
increment(): number;
}
function createCounter(start: number): Counter {
let value = start;
return {
value,
increment() {
value += 1;
return value;
},
};
}
const counter = createCounter(10);
console.log(counter.increment());
console.log(counter.increment());
Output:
11
12
Because this file has a top-level export interface Counter, TypeScript treats it as a module. That single fact is what makes every import that later points at this file subject to the resolution rules discussed here — a script file, by contrast, is never “resolved,” it’s just included wholesale.
Example 2: simulating the extension-lookup order for relative imports. When you write a relative specifier without an extension, TypeScript tries a fixed list of extensions, in order, until one matches a real file. The snippet below is a small, runnable simulation of that logic (simplified — the real compiler also accounts for allowJs, declaration-only packages, and directory index files) so you can see the priority concretely instead of just reading about it.
type ResolutionAttempt = {
path: string;
found: boolean;
};
function resolveWithExtensions(
basePath: string,
extensions: string[],
existingFiles: string[]
): string | undefined {
const attempts: ResolutionAttempt[] = [];
for (const ext of extensions) {
const candidate = `${basePath}${ext}`;
const found = existingFiles.includes(candidate);
attempts.push({ path: candidate, found });
if (found) {
return candidate;
}
}
return undefined;
}
const filesOnDisk = ["./utils.ts", "./utils.d.ts"];
const extensionOrder = [".ts", ".tsx", ".d.ts"];
const resolved = resolveWithExtensions("./utils", extensionOrder, filesOnDisk);
console.log(resolved);
Output:
./utils.ts
Even though ./utils.d.ts also exists, ./utils.ts wins because .ts is tried first. This mirrors the real compiler: a genuine source file is always preferred over a plain declaration file when both are present, so your edits to the .ts file are the ones actually type-checked.
Example 3: simulating non-relative resolution walking up through node_modules. For a bare specifier like "date-helpers", TypeScript (like Node) doesn’t just look in one folder — it walks upward from the importing file’s directory, checking a node_modules folder at every ancestor level, until it finds a match or reaches the filesystem root.
interface PackageInfo {
name: string;
types?: string;
main?: string;
}
function findPackageInAncestors(
startDir: string,
packageName: string,
nodeModulesIndex: Record>
): PackageInfo | undefined {
const ancestors = ["/project/src/components", "/project/src", "/project"];
const startIndex = ancestors.indexOf(startDir);
const searchDirs = startIndex >= 0 ? ancestors.slice(startIndex) : ancestors;
for (const dir of searchDirs) {
const pkgs = nodeModulesIndex[dir];
if (pkgs && pkgs[packageName]) {
return pkgs[packageName];
}
}
return undefined;
}
const nodeModulesIndex: Record> = {
"/project": {
"date-helpers": { name: "date-helpers", types: "index.d.ts", main: "index.js" },
},
};
const pkg = findPackageInAncestors("/project/src/components", "date-helpers", nodeModulesIndex);
console.log(pkg?.types);
Output:
index.d.ts
Even though the import happens three directories deep, TypeScript keeps climbing until it finds a node_modules/date-helpers at /project. Once found, it reads the package’s package.json: the types (or typings) field tells the compiler where that package’s type declarations live, separate from the main field, which points at the actual runtime JavaScript entry point.
Under the Hood: Step by Step
- 1. Classify the specifier. Starts with
./or../? Relative. Anything else — a bare name, scoped package, or configured alias — is non-relative. - 2. Check
pathsfirst, if configured. IfbaseUrlandpathsare set and the specifier matches an alias pattern, TypeScript rewrites it to the mapped location before doing anything else. - 3. Relative resolution. Resolve against the importing file’s directory. Try the exact path; if not found, try appending extensions in order (
.ts,.tsx,.d.ts, plus.js/.jsxifallowJsis on); if the path is a directory, look for anindexfile or apackage.jsonwith atypes/mainfield inside it. - 4. Non-relative resolution. Walk up from the current directory, checking
node_modules/<package>at each ancestor level. Inside a matched package, read itspackage.json: modern resolution consults theexportsmap first (withtypes/import/requireconditions), older-style resolution falls back to thetypes/typingsfield, or a same-named.d.tsnext to themainfile. - 5. Fall back to
@types. If a package ships no type declarations of its own, TypeScript looks innode_modules/@types/<package>for community-authored ambient declarations (the DefinitelyTyped project). - 6. Type-check, then erase. Once resolution succeeds, the compiler type-checks against the found declarations. When it emits JavaScript, all type-only information disappears — the emitted
import/requirestatement keeps your original specifier string verbatim. Resolving that string again, at runtime, is entirely up to Node, the browser, or your bundler — TypeScript has no further say in it.
Common Mistakes
Mistake 1: a case mismatch in a relative import. This works locally on a case-insensitive filesystem (default on Windows and macOS) but breaks in CI on Linux.
// helpers.ts
export function shout(text: string): string {
return text.toUpperCase();
}
// index.ts — imports with the wrong case
import { shout } from "./Helpers";
console.log(shout("hello"));
On a case-sensitive filesystem, tsc reports error TS2307: Cannot find module './Helpers' or its corresponding type declarations. — because the real file is named helpers.ts, not Helpers.ts. Fix it by matching the exact case of the file on disk:
// index.ts — matches the file name's exact case
import { shout } from "./helpers";
console.log(shout("hello"));
Mistake 2: assuming paths aliases work at runtime. paths only affects how the type-checker locates files — it is never rewritten into the emitted JavaScript.
// tsconfig.json has: baseUrl ".", paths: { "@utils/*": ["src/utils/*"] }
import { formatCurrency } from "@utils/format";
console.log(formatCurrency(1999));
This type-checks perfectly. But run the compiled output with plain Node and it throws Error: Cannot find module '@utils/format', because the emitted require("@utils/format") still contains that literal alias string, and Node has no idea what @utils means. Either use a real relative path, or pair the alias with a runtime resolver (like tsc-alias or tsconfig-paths) or a bundler that understands the same alias:
import { formatCurrency } from "../utils/format";
console.log(formatCurrency(1999));
Mistake 3: omitting file extensions under NodeNext resolution. Extension-less relative imports are fine under the classic node strategy, but node16/nodenext mirror real Node ESM rules, which require an explicit extension.
// package.json: { "type": "module" }
// tsconfig.json: { "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" } }
import { formatCurrency } from "./format";
console.log(formatCurrency(4200));
The compiler reports TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './format.js'?. The fix looks strange the first time you see it — you reference .js even though the source file is format.ts — because the specifier must match what the emitted, running JavaScript file will actually be called:
import { formatCurrency } from "./format.js";
console.log(formatCurrency(4200));
Best Practices
- Pick
moduleResolutionto match how your code actually runs:nodenextfor real Node ESM/CJS projects,bundlerfor apps built with Vite/webpack/esbuild, and avoidclassicentirely in new code. - Never treat
pathsaliases as a runtime feature by themselves — pair them with a bundler or a tool liketsc-alias/tsconfig-paths, or just use relative imports for anything that runs directly under Node. - Turn on
forceConsistentCasingInFileNamesso case-mismatched imports fail fast on your own machine instead of surprising you in CI. - Enable
esModuleInteropwhen consuming older CommonJS packages to avoid awkwardimport * as xworkarounds for default exports. - When authoring a library, ship your own
.d.tsfiles (via"declaration": true) rather than relying on a community@typespackage that can drift out of sync with your API. - If you switch a project to
NodeNext, budget time to add explicit file extensions to every relative import — it’s a mechanical but real migration cost. - Remember resolution is compile-time only: always sanity-check a change by actually running the compiled output, not just by watching
tscreport zero errors.
Practice Exercises
- You have
utils.tsandutils.d.tsin the same folder, and code that doesimport "./utils"under standard Node-style resolution. Which file does the compiler prefer for type information, and why? - Configure a
baseUrl/pathsalias for@config/*pointing atsrc/config/*in a sampletsconfig.json, then write one sentence explaining what extra step is needed before code using that alias can actually run under plain Node. - Take an import written as
import { x } from "./thing"in a project wheretsconfig.jsonsets"module": "NodeNext"andpackage.jsonsets"type": "module". Predict the exacttscerror code and message, then write the corrected import.
Summary
- Module resolution is how TypeScript maps an import specifier to an actual file — it happens purely at compile time, for type-checking purposes.
- Relative specifiers (
./,../) resolve against the importing file’s directory; non-relative specifiers resolve by searchingnode_modulesupward through ancestor directories, or via a configuredpathsalias. - The
moduleResolutionoption chooses the algorithm:node10for classic CommonJS-style lookup,node16/nodenextfor accurate modern Node ESM/CJS behavior,bundlerfor bundler-driven apps, andclassiconly for legacy code. pathsaliases only affect the type-checker — they are never rewritten into emitted JavaScript, so runtime resolution needs its own separate handling.- Types are fully erased at emit time; the JavaScript runtime resolves your import specifiers independently, using its own rules, which is why compiled-but-broken bugs happen.
