- Book: TypeScript in Production — Tooling, Build, and Library Authoring
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You move a working app to a published package. The tsconfig stays
the same. The code compiles. You npm publish, install it in a
fresh project, and the import line you wrote a hundred times throws
on the first run.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/node_modules/my-lib/dist/util' imported from
/app/node_modules/my-lib/dist/index.js
Did you mean to import "./util.js"?
The import resolved on your machine. It resolved in CI. It fails the
moment a real Node ESM runtime tries to load it. The reason is one
tsconfig field most people copy from a template and never read:
moduleResolution.
It is the field that decides how import { x } from "./util" turns
into a file on disk. Get it wrong and the compiler validates paths
the runtime cannot find. Here is what each mode does, when you owe
the .js extension, and how package.json exports maps fit in.
What moduleResolution actually controls
moduleResolution answers one question: given a specifier string
like "./util" or "lodash" or "#internal/db", which file does
the compiler look for, and where.
It does not control what code TypeScript emits — that is
module. The two are coupled, but they are separate dials. You can
emit ESM and resolve like a bundler, or emit CommonJS and resolve
the strict Node way. Mixing them wrong is the root of most
"works locally, breaks on install" reports.
The modes you will meet in 2026:
-
node10(wasnode) — legacy CommonJS resolution. Deprecated. -
node16/nodenext— models real Node behavior, ESM and CJS. -
bundler— models how Vite, esbuild, webpack, and Bun resolve.
The old classic mode is gone. If you still have it, the compiler
errors on startup and tells you to pick a real one.
bundler: the forgiving one
moduleResolution: "bundler" exists because bundlers were already
resolving imports more loosely than Node, and TypeScript needed a
mode that matched them instead of fighting them.
Under bundler, you write extensionless imports and the compiler is
happy:
// resolves to ./util.ts at build time
import { parse } from "./util";
// reads package.json "exports", no extension needed
import { z } from "zod";
It reads package.json exports maps, supports paths, and lets you
omit file extensions. That is exactly what Vite and esbuild do, so
the types match what ships.
The catch: bundler assumes a bundler runs later. The output is
never executed by Node directly. If you point bundler at a build
that Node loads raw, the extensionless imports the compiler accepted
become the ERR_MODULE_NOT_FOUND from the opening. The compiler was
never lying — it was modeling a step you skipped.
Use bundler for apps: anything Vite, Next, Bun, or esbuild builds
before it runs. It is the default when module is esnext or
preserve, and it is the right default for most application code.
node16 / nodenext: the strict one
node16 and nodenext model how Node itself resolves modules. In
2026 they behave the same way; nodenext just tracks the latest
Node semantics as they land. Pick one and treat them as one mode.
This is the mode that demands the .js extension.
// WRONG under node16 in an ESM package
import { parse } from "./util";
// RIGHT — the extension is mandatory
import { parse } from "./util.js";
Read that twice if it looks wrong. You import ./util.js from a
file named util.ts. The extension refers to the emitted file,
not the source. TypeScript resolves ./util.js against util.ts
for type checking, then leaves the specifier untouched in the
output, so Node finds util.js at runtime.
It feels backwards the first time. It is correct: the import string
is what ships, and what ships must point at a real .js file,
because Node's ESM loader does no extension guessing.
The other thing node16 enforces: the file's module format is
decided by package.json "type" and the file extension, not by
your tsconfig.
// package.json
{
"type": "module"
}
With "type": "module", every .js is ESM and every .ts emits
ESM. A .cts file is always CommonJS; a .mts file is always ESM.
node16 reads those rules the way Node does and errors when you
break them — for example, using import.meta in a file that resolves
as CommonJS.
Use node16/nodenext for anything Node runs without a bundler:
published libraries, CLIs, backend services that node dist/index.js
straight from tsc output.
How exports maps change resolution
The exports field in package.json is the other half of the
story. Once a package has one, it is the only way in. Deep
imports that used to work stop working.
// my-lib/package.json
{
"name": "my-lib",
"type": "module",
"exports": {
".": "./dist/index.js",
"./parser": "./dist/parser.js"
}
}
With that map, a consumer can do this:
import { main } from "my-lib";
import { parse } from "my-lib/parser";
And cannot do this, even though the file is right there on disk:
// blocked by the exports map
import { parse } from "my-lib/dist/parser.js";
node16 and bundler both respect exports maps. node10 does not,
which is why a dependency resolves under the old mode and 404s under
the new one. If you bump a consumer to node16 and a third-party
import breaks, the package added an exports map and your old
deep-path import was never meant to survive.
Conditional exports let one package serve both module systems:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
The types condition must come first or some resolvers miss it. If
your published types resolve to nothing under node16, a misordered
exports map is the usual cause.
The decision table
Match the mode to what runs the output, not to what feels modern.
| Project shape | module | moduleResolution | .js ext? |
|---|---|---|---|
| Vite / Next / Bun app |
esnext / preserve
|
bundler |
no |
| Published npm library | nodenext |
nodenext |
yes |
| Node backend, no bundler | nodenext |
nodenext |
yes |
| Node backend, tsx/esbuild | esnext |
bundler |
no |
| Dual ESM + CJS package | nodenext |
nodenext |
yes |
| Monorepo internal package | esnext |
bundler |
no |
Two rules sit under the whole table:
- If Node loads your output directly, you owe the
.jsextension and you wantnodenext. - If a bundler stands between your output and the runtime, drop
bundlerin and skip the extensions.
The trap in the opening was an app tsconfig (bundler, no
extensions) shipped as a library that Node loads raw. The fix is one
field, plus adding .js to the relative imports.
Migrating an existing project
If you are on node (now node10) and the compiler nags you,
move deliberately:
// tsconfig.json — library or raw-Node service
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}
Then expect a wave of "relative import paths need explicit file
extensions" errors. That wave is the point — it lists every import
that would have failed at runtime. A codemod such as
ts-add-js-extension
rewrites them in one pass. Run it, build, and run the output under
Node before you trust the green check.
For an app, the move is the opposite direction and quieter:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
}
}
No extension churn, because the bundler resolves them. Confirm your
tsx or vite-node dev runner agrees with the production bundler;
when dev and build use different resolvers you get imports that pass
in one and fail in the other.
The one mental model that fixes it
Stop asking which mode is correct in the abstract. Ask one question:
after tsc runs, who loads the files?
A bundler loads them → bundler, no extensions. Node loads them raw
→ nodenext, .js extensions, and a package.json whose "type"
and exports you actually wrote on purpose. The import that breaks
on install is almost always an app config shipped where a library
config belonged.
The .js extension is not a bug or a TypeScript quirk to route
around. It is the runtime telling you, at compile time, exactly what
the file will be called when Node goes looking for it.
If this was useful
The full resolution story — nodenext vs bundler, dual ESM/CJS
publishing, exports maps that serve both module systems, and the
monorepo configs where this gets sharp — is the spine of TypeScript
in Production, book 5 in The TypeScript Library. If the decision
table above is the kind of thing you keep relearning, the chapter on
build and module config covers it end to end.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)