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 innode_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,
exportshonored. - preserve
- A
modulesetting 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
typesandimport. - Condition
- A key in
exportssuch astypes,import,requireordefaultthat 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):
$ 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.
{
"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.
{
"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.
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.
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 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:
# "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
Spot the extension rule
In the first example, change
"./shapes"to"./shapes.js". Predict the first error line: it is stillindex.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
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
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,moduleResolutionand whether import paths need.js. Compare with the answer to the second interview question.
Code & diagrams
./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
objectimport(...) 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 runtimeBuilt 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.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";.
Break #2
Import a file the package keeps private
Import "shapes/dist/secret.js" from a package whose exports lists only . and ./colors.
Break #3
Use a removed option
Set "moduleResolution": "node" in tsconfig.
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.
- ▸
nodenextfollows the rules of the Node versions current when your TypeScript shipped. Libraries that want a fixed target can picknode16,node18ornode20explicitly. Under these settings TypeScript checks per file whether it is ESM or CJS and reports the interop problems Node would have. - ▸
The
typescondition should come beforedefaultinexportsbecause resolution takes the first matching condition. A package publishing both ESM and CJS needs two sets of declaration files (.d.mtsand.d.cts); one shared.d.tscan make the types lie for one of the two formats. - ▸
Resolution is a large part of a compile.
tsc --traceResolutionprints 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
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 intonode_modules): the compiler walks up the folders looking fornode_modules/shapes. Then it must choose a file inside the package, and that is wherepackage.json(exports,types,main) comes in. - 2
modulesays what kind of JavaScript to write, andmoduleResolutionsays how to find files. For almost every project there are only three good combinations. **module: "nodenext"** (which impliesmoduleResolution: "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, butpackage.jsonexportsare honored. **module: "preserve"** leavesimport/exportexactly as you wrote them for a bundler to handle, and resolves like a bundler automatically. - 3
File extensions. Under
nodenext, an ES module import must name the file Node will load: you write"./util.js"even though the source isutil.ts. TypeScript never rewrites import paths, and at runtime onlyutil.jsexists. If you forget, you get TS2835 and the message suggests the right name. Underbundlerorpreserveyou can leave the extension off ("./util"), because the bundler does the searching. Writing.jsstill works there too, so a project can use.jseverywhere and move between modes. - 4
ESM or CommonJS per file. In Node, a
.jsfile is an ES module if the nearestpackage.jsonhas"type": "module", otherwise CommonJS;.mjs/.mtsare always ESM and.cjs/.ctsalways CommonJS.nodenextapplies this to your.tsfiles too. A CommonJS file can't useimport.meta(TS1470). Mixing the two systems is the most common source ofnodenexterrors. (Recall from Topic 11.1: ESM is theimport/exportsystem, CommonJS isrequire/module.exports.) - 5
**
package.jsonexportsis 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 readsexportsundernodenextandbundler. 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 fieldstypes/typingsandmainare only used when there is noexports. - 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 addbaseUrlnext topaths, butpathshas worked alone (relative to the tsconfig) for years.baseUrlwas deprecated in TypeScript 6.0 and is removed in 7.0, so leave it out. - 7
What TypeScript 7.0 removed.
moduleResolution: "node"(also callednode10) andclassicare gone, and so arebaseUrl,module: amd/umd/systemjs/none,target: es5andoutFile. 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 usesmoduleResolution: node, move tobundler(for apps that use a bundler) ornodenext(for Node libraries and servers).
Explain it without notes
What is the difference between module and moduleResolution?
When would you choose bundler over nodenext?
Why do you write .js in an import of a .ts file under nodenext?
What does package.json exports do for TypeScript users and package authors?
Practice
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.
A package's exports is { ".": { "default": "./dist/index.js", "types": "./dist/index.d.ts" } }. Reorder it correctly and say why.
Trade-offs
- ↔
nodenextis strict and matches Node, so you find import mistakes at compile time, at the cost of writing.jsextensions and thinking about ESM vs CJS.bundleris 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. - ↔
pathsaliases 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
moduleandmoduleResolutioneach control.I can choose between
nodenext,bundlerandpreservefor a given project.I know why
nodenextneeds.jsin imports and what TS2835 means.I can read a package's
exportsand say which paths are public.I know that
baseUrlandmoduleResolution: nodeare removed in 7.0 and what to use instead.