---
title: 'Module Resolution'
source: 'https://academia.sh/en/courses/typescript/module-resolution'
course: TypeScript
language: en
updated: '2026-08-17T18:09:55+00:00'
license: 'CC BY-SA 4.0'
---

# Module Resolution

The compiler's rules for turning a module name into a file, the extension requirement, resolution strategies, the limit of path mapping on output, and the fix through package subpath imports.

The import line in the previous lesson wrote `./legacy.js`, and the extension was
required. There, the imported file really was a JavaScript file; the same notation
does not change even when the source is a TypeScript file.

This is one of the rules of **module resolution**: the compiler's process of turning a
module name into a file. The rules depend on the `moduleResolution` setting, the
file's module kind, and the package's manifest. This lesson covers the process and
its traps.

## The Extension Requirement

Consider a project. Let the `package.json` file be
`{ "name": "measurement-modules", "type": "module", "version": "1.0.0" }`, and the
configuration be this:

```json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"]
}
```

Let `src/data/measurement.ts` export the course's model:

```typescript
export interface Measurement {
  id: string;
  value: number;
}

export function average(records: readonly Measurement[]): number {
  return records.length === 0
    ? 0
    : records.reduce((t, r) => t + r.value, 0) / records.length;
}
```

Three different notations can be tried inside `src/main.ts`. Without an extension:

```typescript
import { average } from "./data/measurement";

console.log(average([{ id: "s-01", value: 21.4 }]));
```

```text
src/main.ts(1,25): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './data/measurement.js'?
```

With the source file's real name, that is, the `.ts` extension:

```typescript
import { average } from "./data/measurement.ts";

console.log(average([{ id: "s-01", value: 21.4 }]));
```

```text
src/main.ts(1,25): error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
```

And in the form the compiler suggested:

```typescript
import { average } from "./data/measurement.js";

console.log(average([{ id: "s-01", value: 21.4 }]));
```

This third form compiles. The produced `dist/main.js`:

```javascript
import { average } from "./data/measurement.js";
console.log(average([{ id: "s-01", value: 21.4 }]));
```

The output of `node dist/main.js` is `21.4`.

The rule is surprising, and its reason lies in the type erasure principle: **the
compiler does not change import paths.** Whatever is written in the source stays in
the output. Since the JavaScript file that will run is `measurement.js`, that has to
be what is written in the source too. Writing the `.ts` extension means referring to a
file that does not exist in the output.

There are options that change this behavior: flags that allow writing a `.ts`
extension and rewrite the extension in the output when needed. Which ones exist
depends on the compiler version; knowing the principle makes it possible to
understand what any given flag does.

## Resolution Strategies

The `moduleResolution` setting determines which rules a name is searched with. There
are three classes.

**Strategies matching the runtime rule** (`node16`, `nodenext`) mimic the server
runtime's module resolution algorithm: the extension is required, the package manifest
is read, whether a file is a module or a script is determined by the manifest's type
field. The diagnostics above come from this class.

**The strategy matching a bundler** (`bundler`) assumes a bundler is present at the
build step. No extension is required, because it is the bundler, not the compiler,
that will resolve the path:

```json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}
```

With this configuration, the notation `import { average } from "./data/measurement";`
checks without error.

**The old strategy** (`node10`) is the first-generation algorithm that does not read
details in the package manifest, and it is not chosen in new projects.

The selection criterion is this: **the rule of whoever will run the code is
chosen.** If output goes directly to a runtime, the runtime strategy; if it passes
through a bundler, the bundler strategy. The symptom of a wrong choice is type
checking passing while running fails.

## Package Names and Finding Type Declarations

A non-relative name — a package name — is resolved a different way: the compiler
walks up the file tree, searches for package directories, and reads the manifest of
the package it finds.

A package manifest can point to both a runtime file and a type file for the same
name:

```json
{
  "name": "measurement-driver",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/driver.d.ts",
      "default": "./dist/driver.js"
    }
  }
}
```

The `types` condition answers to the compiler, the `default` condition to the
runtime. If the package publishes the declaration
`export declare function read(channel: string): Promise<number>;` in
`dist/driver.d.ts`, the contract is enforced on the consumer's side:

```typescript
import { read } from "measurement-driver";

export async function firstReading(): Promise<number> {
  return read("temperature.C");
}

export async function invalidReading(): Promise<number> {
  return read(42);
}
```

```text
src/main.ts(8,15): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
```

Which steps resolution goes through can be asked. The `--traceResolution` option
writes this; in the output below the project root's absolute path is shortened to the
form `.../app`:

```text
======== Resolving module 'measurement-driver' from '.../app/src/main.ts'. ========
Loading module 'measurement-driver' from 'node_modules' folder, target file types: TypeScript, JavaScript, Declaration, JSON.
Found 'package.json' at '.../app/node_modules/measurement-driver/package.json'.
File '.../app/node_modules/measurement-driver/dist/driver.d.ts' exists - use it as a name resolution result.
======== Module name 'measurement-driver' was successfully resolved to '.../app/node_modules/measurement-driver/dist/driver.d.ts' with Package ID 'measurement-driver/dist/driver.d.ts@1.0.0'. ========
```

The output is the most direct way to find where resolution gets stuck: the question
"why aren't this package's types showing up" is answered by the lines showing which
file was searched for and not found.

If a package does not publish its own declarations, two options remain. The first is
installing declarations published separately by the community; these packages are
gathered under a name prefix and the compiler finds them on its own. The second is
writing the ambient declaration from the Declaration Files lesson. Both carry the same
debt: the declaration has to be kept in sync by hand with the package's real behavior.

## Path Mapping and Its Limit

In deep directory structures, relative paths become hard to read. The `paths` setting
establishes a mapping between a name and a file:

```json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "paths": {
      "@data/*": ["./src/data/*"]
    }
  },
  "include": ["src"]
}
```

```typescript
import { average } from "@data/measurement.js";

console.log(average([{ id: "s-01", value: 21.4 }]));
```

This file **passes type checking without error**. The produced `dist/main.js`:

```javascript
import { average } from "@data/measurement.js";
console.log(average([{ id: "s-01", value: 21.4 }]));
```

Running `node dist/main.js`:

```text
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@data/measurement.js'
```

The reason is the same as before: the compiler does not change import paths. The
`paths` setting only makes the **compiler** resolve the name; the runtime knows
nothing of the same mapping.

This is the last and most expensive example of the distinction established in the
course's first lesson. The type layer belongs to compile time; it does not change
runtime behavior. `paths` is a type-layer setting, not a module-system setting.

Also, the `baseUrl` setting often used alongside `paths` has been removed outright in
this compiler version and produces `TS5102` when written. Path patterns are now
written directly relative to the directory the configuration file sits in.

## The Correct Fix: Package Subpath Imports

The same readability can be obtained a way the runtime also knows about. An internal
path mapping is written into the package manifest:

```json
{
  "name": "measurement-modules",
  "type": "module",
  "version": "1.0.0",
  "imports": {
    "#data/*": "./dist/data/*"
  }
}
```

`paths` is removed from the configuration and the import takes this form:

```typescript
import { average } from "#data/measurement.js";

console.log(average([{ id: "s-01", value: 21.4 }]));
```

Compilation passes without error, the `dist/main.js` output keeps the import as is,
and running `node dist/main.js` gives `21.4`.

The difference is this: the `#data/*` pattern is a property of the **package
manifest**, not the compiler. Because the `nodenext` strategy reads the package
manifest, the compiler uses the same mapping too. A single source, two consumers.

General principle: **the resolution rule is defined where the runtime will
understand it.** A rule known only to the compiler opens a gap between output and
source.

The situation is different in projects using a bundler: there, it is the bundler that
resolves paths, and the `paths` setting is safe as long as it is kept **identical**
to the bundler's own mapping. Two pieces of information kept in two places drifting
apart is a known maintenance problem.

## Summary

- The compiler does not change import paths; the name written in the source stays
  exactly the same in the output.
- Runtime strategies require an extension on relative paths, and the extension is the
  output's name, not the source's; writing `.ts` gives `TS5097`, writing no extension
  gives `TS2835`.
- The bundler strategy requires no extension, because the bundler resolves the path;
  the strategy is chosen according to whoever will run the code.
- The `paths` setting only affects the compiler's resolution; because the runtime
  does not know the same mapping, the output can fail to run.
- A package manifest's internal path mapping is read by both the compiler and the
  runtime, giving a single-source solution.

## Next Step

Throughout the course, the compiler both did type checking and produced output.
Splitting these two jobs is a decision that determines build time and deployment
layout in large projects. The final lesson covers this split, publish behavior on
error, and all the layers the course has built, together.
