Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~23 min· topic 2 of 6

Topic 11.2

Module Resolution

In one line

Module resolution is how the compiler turns the text in import "./util" or import "shapes" into a real file. The module and moduleResolution settings pick the rulebook: nodenext copies Node.js, bundler copies Vite and webpack, and preserve leaves your imports alone.

Think of it like this

You tell a taxi driver "take me to the bakery". Which bakery? In a small town there is one. In a big city the driver asks: "on which street?", and different drivers (a bus, a taxi, a delivery bike) know different shortcuts. Module resolution is the driver: given the words in an import, it decides which file you mean. Node.js, Vite and webpack are three drivers with different rules, and TypeScript must follow the one that will really run your code, or it will approve imports that crash.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Module resolution
Turning the text of an import into the path of a real file.
Relative import
An import that starts with ./ or ../ and points near the current file.
Bare specifier
An import without a dot, like "react": the name of a package in node_modules.
nodenext
Resolution that copies Node.js exactly, including file extensions and ESM vs CommonJS.
bundler
Resolution for code that Vite, webpack or esbuild will bundle: extensions optional, exports honored.
preserve
A module setting that leaves imports and exports as written and resolves like a bundler.
exports (package.json)
A map from public import paths to files, with conditions like types and import.
Condition
A key in exports such as types, import, require or default that picks a file for the situation.
paths
A tsconfig map of aliases like @/* to folders, used only by TypeScript.

Step by step

01The same import under two rulebooks

A tiny project: src/util.ts exports double, and src/main.ts imports it with no extension. Under module: nodenext (with "type": "module" in package.json), tsc 7.0.2 says (real output):

terminalwhole filebash
$ tsc -p .
src/main.ts(1,24): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?

02Follow the hint, or change the rulebook

Write "./util.js" and the error is gone (exit code 0). That is the right answer for code Node runs directly.

If a bundler will build the app, set "module": "esnext", "moduleResolution": "bundler" instead; then the extension-less import compiles too, with no error (real run). Same code, different driver.

tsconfig.jsonwhole filejson
{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "target": "esnext",
    "strict": true,
    "noEmit": true,
    "types": []
  },
  "include": ["src"]
}

03A package that says what is public

Here is a small package inside node_modules (made for this lesson). Its exports lists two public entries, each with its own types. Only these can be imported.

node_modules/shapes/package.jsonwhole filejson
{
  "name": "shapes",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./colors": {
      "types": "./dist/colors.d.ts",
      "default": "./dist/colors.js"
    }
  }
}

04Public paths work, private ones don't

The app imports "shapes" and "shapes/colors", which are listed, and also "shapes/dist/secret.js", a real file the package author never made public. tsc 7.0.2 accepts the first two and rejects the third (real output). Color was imported with the type keyword, as Topic 11.1 taught.

src/main.tswhole filets
import { area } from "shapes";
import { palette, type Color } from "shapes/colors";
import { secret } from "shapes/dist/secret.js";

const c: Color = "red";
console.log(area(2, 3), palette, c, secret);
// src/main.ts(3,24): error TS2307: Cannot find module 'shapes/dist/secret.js' or its corresponding type declarations.

05Dual packages: import vs require

A package can ship an ESM build and a CommonJS build with different types, selected by the importer. In this test package, import points to index.d.mts (declares flavor: "esm") and require to index.d.cts (flavor: "cjs").

The same line import { flavor } from "dual" gives type "esm" in main.ts (an ES module, because package.json says "type": "module") and "cjs" in old.cts. Assigning it to the wrong literal type fails in the .ts file with error TS2322: Type '"esm"' is not assignable to type '"cjs"'. (real; the identical line in old.cts was accepted). One package, two answers: that's nodenext working as Node does.

Dual packages: import vs requirediagram
Rendering diagram…

06paths: aliases without baseUrl

paths alone is enough now. A broken alias shows TS2307 (real run on a project with src/lib/greet.ts and no missing.ts):

tsconfig.json + terminalwhole filebash
# tsconfig.json compilerOptions: "moduleResolution": "bundler",
#   "paths": { "@/*": ["./src/*"] }
$ tsc -p .
src/ui/button.ts(2,22): error TS2307: Cannot find module '@/lib/missing' or its corresponding type declarations.
# "@/lib/greet" resolved fine; only the missing file is reported.

07What TypeScript 7.0 says to old settings

Real output from the same small project with the removed options:

terminalwhole filebash
# "baseUrl": "./src"   (tsc 7.0.2)
tsconfig.json(2,75): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
  Use '"paths": {"*": ["./src/*"]}' instead.

# "moduleResolution": "node"   (tsc 7.0.2)
tsconfig.json(2,64): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.

# the same "moduleResolution": "node"   (tsc 6.0.3)
tsconfig.json(2,64): error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
  Visit https://aka.ms/ts6 for migration information.

Try it yourself

  1. 1

    Spot the extension rule

    In the first example, change "./shapes" to "./shapes.js". Predict the first error line: it is still index.ts(1,28) (the error points at the start of the quoted path), but the message now names './shapes.js', because the file still does not exist here.

  2. 2

    Why does the second one reach the catch?

    In the second example, change the path to a different typo such as "./misssing.js". The type error stays, the console stays the same. What do you learn? TypeScript and the browser each check the path separately.

  3. 3

    Choose for your own project

    Think of a project (a Vite React app? a Node API? a published library?) and choose the three settings: module, moduleResolution and whether import paths need .js. Compare with the answer to the second interview question.

Code & diagrams

Reading a failed import (a type-only import still runs)

./shapes does not exist in this one-file editor, so tsc reports TS2307 and treats Shape as any. The import type line is erased, so the program still runs. Real projects see this same message when a path or a package name is wrong.

Console

(nothing was printed)

Expected console output

object
A dynamic import() is a Promise, and a missing file fails at runtime

import(...) with parentheses loads a module later, when the line runs, and returns a Promise. TypeScript can't find ./missing.js (TS2307) and types mod as any. The browser then really fails to load it, so the catch runs.

Console

(nothing was printed)

Expected console output

the file is not there at runtime
Project: the nodenext import error and its fix (real tsc 7.0.2)bash

Built in a scratch project: package.json has "type": "module", tsconfig has module nodenext.

$ cat src/main.ts
import { double } from "./util";
console.log(double(21));

$ tsc -p .
src/main.ts(1,24): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?

$ sed -i 's#"./util"#"./util.js"#' src/main.ts && tsc -p . ; echo "exit $?"
exit 0

# Without "type": "module" the same file is CommonJS, and import.meta is refused:
src/main.ts(4,13): error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.
Project: module preserve resolves like a bundlerjson

With module preserve and no moduleResolution line, the extension-less import of ./util compiled with exit code 0. tsc --showConfig printed exactly the options below, so nothing else was set.

{
    "compilerOptions": {
        "module": "preserve",
        "noEmit": true,
        "strict": true,
        "target": "esnext",
        "types": []
    }
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the extension under nodenext

In a project with "module": "nodenext" and "type": "module", write import { double } from "./util";.

terminal
$ tsc -p .
── what you'll see ──
src/main.ts(1,24): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?

Break #2

Import a file the package keeps private

Import "shapes/dist/secret.js" from a package whose exports lists only . and ./colors.

terminal
$ tsc -p .
── what you'll see ──
src/main.ts(3,24): error TS2307: Cannot find module 'shapes/dist/secret.js' or its corresponding type declarations.

Break #3

Use a removed option

Set "moduleResolution": "node" in tsconfig.

terminal
$ tsc -p . (TypeScript 7.0.2)
── what you'll see ──
tsconfig.json(2,64): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.

Myth vs fact

Myth

paths makes the alias work at runtime.

Fact

paths only tells TypeScript where to look. Your bundler or Node needs its own alias setting, or the app crashes although the types are happy.

Myth

TypeScript rewrites ./util to ./util.js for me.

Fact

TypeScript doesn't change the text of import paths in the output. The only exception is the opt-in rewriteRelativeImportExtensions, which turns .ts paths into their .js equivalent. What you write is otherwise what Node or the bundler sees.

Myth

moduleResolution: node is the safe default.

Fact

It was, years ago. It ignores exports, so it can import files a package keeps private and miss the right types. It is removed in TypeScript 7.0.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    nodenext follows the rules of the Node versions current when your TypeScript shipped. Libraries that want a fixed target can pick node16, node18 or node20 explicitly. Under these settings TypeScript checks per file whether it is ESM or CJS and reports the interop problems Node would have.

  • ▸

    The types condition should come before default in exports because resolution takes the first matching condition. A package publishing both ESM and CJS needs two sets of declaration files (.d.mts and .d.cts); one shared .d.ts can make the types lie for one of the two formats.

  • ▸

    Resolution is a large part of a compile. tsc --traceResolution prints every candidate path TypeScript tried and why it stopped, which is the tool when "it works in Node but TypeScript can't find it".

Remember this

  1. 1

    The problem this solves: an import is just text, such as "./util", and the compiler must find the same file that Node or your bundler will find at run time. If the two disagree, the code type-checks but crashes. Module resolution is the set of rules that keeps them in agreement. There are two kinds of import path. A relative path starts with ./ or ../ ("./util.js") and points to a file near yours. A bare specifier has no dot ("shapes", "react") and names a package (a folder of code installed by npm into node_modules): the compiler walks up the folders looking for node_modules/shapes. Then it must choose a file inside the package, and that is where package.json (exports, types, main) comes in.

  2. 2

    module says what kind of JavaScript to write, and moduleResolution says how to find files. For almost every project there are only three good combinations. **module: "nodenext"** (which implies moduleResolution: "nodenext") is for code that runs directly in Node.js: it follows Node's real rules. **module: "esnext" + moduleResolution: "bundler"** (bundler resolution since 5.0) is for code that Vite, webpack, esbuild or Next.js bundles: relative imports need no extension, but package.json exports are honored. **module: "preserve"** leaves import/export exactly as you wrote them for a bundler to handle, and resolves like a bundler automatically.

  3. 3

    File extensions. Under nodenext, an ES module import must name the file Node will load: you write "./util.js" even though the source is util.ts. TypeScript never rewrites import paths, and at runtime only util.js exists. If you forget, you get TS2835 and the message suggests the right name. Under bundler or preserve you can leave the extension off ("./util"), because the bundler does the searching. Writing .js still works there too, so a project can use .js everywhere and move between modes.

  4. 4

    ESM or CommonJS per file. In Node, a .js file is an ES module if the nearest package.json has "type": "module", otherwise CommonJS; .mjs/.mts are always ESM and .cjs/.cts always CommonJS. nodenext applies this to your .ts files too. A CommonJS file can't use import.meta (TS1470). Mixing the two systems is the most common source of nodenext errors. (Recall from Topic 11.1: ESM is the import/export system, CommonJS is require/module.exports.)

  5. 5

    **package.json exports is how a package says which files are public and which types go with them. It is a map from subpaths to files, with conditions**: "types" (the .d.ts), "import" (used when the importer is ESM), "require" (used by CommonJS), "default". TypeScript reads exports under nodenext and bundler. Anything not listed is private: import "shapes/dist/secret.js" fails with TS2307 even though the file exists on disk. Put "types" first in each block, because conditions are matched in order. The old fields types/typings and main are only used when there is no exports.

  6. 6

    **paths** lets you write aliases like "@/lib/greet". It maps an import pattern to folders: "paths": { "@/*": ["./src/*"] }. It only changes how TypeScript finds files. Your bundler or Node must be set up with the same alias, or the build passes and the program crashes. Old guides tell you to add baseUrl next to paths, but paths has worked alone (relative to the tsconfig) for years. baseUrl was deprecated in TypeScript 6.0 and is removed in 7.0, so leave it out.

  7. 7

    What TypeScript 7.0 removed. moduleResolution: "node" (also called node10) and classic are gone, and so are baseUrl, module: amd/umd/systemjs/none, target: es5 and outFile. TypeScript 6.0 still accepted them with an error unless you wrote "ignoreDeprecations": "6.0"; 7.0 has no switch to bring them back. If an old project uses moduleResolution: node, move to bundler (for apps that use a bundler) or nodenext (for Node libraries and servers).

Explain it without notes

01

What is the difference between module and moduleResolution?

02

When would you choose bundler over nodenext?

03

Why do you write .js in an import of a .ts file under nodenext?

04

What does package.json exports do for TypeScript users and package authors?

Practice

01

You have a Vite + React app that currently has moduleResolution: "node" and baseUrl: "." with imports like import { api } from "src/api". Write the replacement compilerOptions fragment for TypeScript 7.

02

A package's exports is { ".": { "default": "./dist/index.js", "types": "./dist/index.d.ts" } }. Reorder it correctly and say why.

Trade-offs

  • ↔

    nodenext is strict and matches Node, so you find import mistakes at compile time, at the cost of writing .js extensions and thinking about ESM vs CJS. bundler is relaxed and fits app code, but it can approve an import that raw Node would reject, so it's a poor choice for a library you publish for Node.

  • ↔

    paths aliases make deep imports short, but you now keep two or three tools (tsc, the bundler, the test runner) in sync with the same alias list.

Done when you can

  • I can explain what module and moduleResolution each control.

  • I can choose between nodenext, bundler and preserve for a given project.

  • I know why nodenext needs .js in imports and what TS2835 means.

  • I can read a package's exports and say which paths are public.

  • I know that baseUrl and moduleResolution: node are removed in 7.0 and what to use instead.