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
.tsgivesTS5097, writing no extension givesTS2835. - The bundler strategy requires no extension, because the bundler resolves the path; the strategy is chosen according to whoever will run the code.
- The
pathssetting 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.