Skip to content
academia.sh

Lesson 22 / 23

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.

Contents

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:

{
  "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:

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:

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

console.log(average([{ id: "s-01", value: 21.4 }]));
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:

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

console.log(average([{ id: "s-01", value: 21.4 }]));
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:

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

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

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

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:

{
  "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:

{
  "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:

import { read } from "measurement-driver";

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

export async function invalidReading(): Promise<number> {
  return read(42);
}
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:

======== 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/[email protected]'. ========

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:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "paths": {
      "@data/*": ["./src/data/*"]
    }
  },
  "include": ["src"]
}
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:

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

Running node dist/main.js:

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:

{
  "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:

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close