Set "moduleResolution": "bundler" in a library’s tsconfig.json and tsc --noEmit reports zero errors on an import missing its file extension — then the same package, installed and required directly by Node.js, throws:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/node_modules/@acme/lib/dist/utils'
imported from /node_modules/@acme/lib/dist/index.js

bundler and nodenext diverge on exactly the checks that matter once a package leaves your repository: file extensions on relative imports and strict enforcement of the exports field. Picking the wrong one for a published library lets invalid imports pass type checking right up until a real Node.js runtime rejects them.


Root Cause

moduleResolution tells TypeScript which resolution algorithm to simulate while type-checking import specifiers — it has no effect on what actually happens at runtime. nodenext (paired with "module": "nodenext") simulates Node.js’s own ESM and CommonJS resolver exactly: relative imports must include the extension the emitted file will have (.js, not .ts), and any subpath not explicitly listed in exports is unresolvable, mirroring ERR_PACKAGE_PATH_NOT_EXPORTED. bundler mode exists for application code that a tool like Vite, webpack, or esbuild will resolve — those tools tolerate extensionless imports and directory-index resolution that Node.js’s native loader does not, so bundler mode relaxes the same checks to match. Using bundler for a package that Node.js will load directly removes the compiler’s ability to catch the exact class of error that surfaces after publish.


Extension and Exports Rules Compared

bundler vs nodenext moduleResolution bundler mode allows extensionless relative imports and is lenient about the exports field because a downstream bundler performs the real resolution, while nodenext requires explicit file extensions and strictly enforces the exports field to match Node.js's actual runtime loader. bundler nodenext extensionless imports allowed exports field checked loosely assumes Vite/webpack/esbuild resolves files best for apps built by a bundler explicit .js extension required exports field enforced exactly mirrors Node.js's real ESM/CJS loader best for a published npm library

Which One to Pick

Publishing a library that Node.js consumers will import or require directly: use nodenext. It is the only mode that reproduces the actual runtime checks — missing extensions and unlisted exports subpaths fail tsc --noEmit before they ever reach a consumer, instead of only after publish.

Building an application whose only entry point is a bundler (a Vite SPA, a webpack-bundled server handler that a platform packages for you): bundler is a reasonable choice, since the bundler’s own resolver is what actually runs, and bundler mode avoids friction from extension rules that don’t apply to your build. Even so, nodenext remains a safe default for applications too — it costs an extra .js on relative imports and gains protection against import mistakes that would otherwise only surface if the code is ever run outside the bundler, for example in a Node.js-executed test file or a ts-node script.

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "declaration": true,
    "strict": true
  }
}
// nodenext requires the runtime extension, not the source extension
import { normalize } from './utils.js';   // correct — utils.ts emits utils.js
import { normalize } from './utils';      // error under nodenext, silently allowed under bundler

HAZARD PREVENTION

Symptom: A library type-checks cleanly with "moduleResolution": "bundler", then a consumer running plain Node.js reports ERR_PACKAGE_PATH_NOT_EXPORTED or ERR_MODULE_NOT_FOUND on an import the compiler never flagged.

Root cause: bundler mode does not enforce the same extension and exports rules Node.js applies at runtime, so invalid imports pass type checking and only fail once the package is actually loaded outside a bundler.

Fix: Switch the library’s tsconfig.json to "module": "nodenext" and "moduleResolution": "nodenext", fix the resulting extension errors, and re-run npx attw --pack . to confirm every entry point resolves for real Node.js consumers.


Verification

# Type-check under nodenext — surfaces every extension and exports violation
npx tsc --noEmit --moduleResolution nodenext --module nodenext

# Confirm the published package actually resolves under Node's real loader
npx attw --pack .

Expected attw output once the package is correctly wired for nodenext:

my-lib
  node10    CJS 🟢
  node16    ESM 🟢  CJS 🟢
  bundler   ESM 🟢

A red result specifically in the node16 row (not bundler) is the strongest signal that bundler mode masked a real resolution failure during development.


Frequently Asked Questions

Why does my import work fine with bundler but break once published?

bundler mode assumes a downstream tool such as Vite, webpack, or esbuild will perform the actual file resolution, so TypeScript is permissive about missing extensions and does not enforce exports map correctness the way Node.js does. Once the package is installed and run directly by Node.js, none of that leniency applies, and an import missing a .js extension or a subpath absent from exports fails at runtime even though tsc never complained.

Can I use nodenext in an application that also uses a bundler?

Yes, and it is a safe default even then. nodenext only tightens the rules TypeScript enforces during type checking; it does not change what your bundler actually does at build time. The extra strictness costs you writing explicit .js extensions in relative imports, which is a small one-time migration that pays off if the app is ever run outside the bundler, for example under ts-node or a server-side Node.js entry point.

Does switching from bundler to nodenext require code changes?

Almost always, yes. The most common change is adding explicit .js extensions to relative imports of TypeScript files, since nodenext requires the extension that will exist at runtime rather than the .ts extension of the source file. Some projects also need to fix subpath imports that were relying on directory index resolution without a corresponding exports entry.

What does moduleResolution: node (the legacy mode) do differently from both?

The legacy node mode predates conditional exports entirely. It ignores the exports field in package.json, allows extensionless relative imports, and resolves purely on file system lookups similar to old CommonJS require() behavior. It is deprecated for new projects and neither reflects modern Node.js runtime resolution nor a bundler’s typical behavior, so it should not be used for a package published after TypeScript 4.7.



↑ Back to Path Mapping and Module Resolution Strategies