TypeScript 6.0 Module Resolution Overhaul: What Bundler Mode Actually Changes for Your Project
TypeScript 6.0's moduleResolution: bundler fundamentally changes how import paths resolve. Learn what breaks, when to migrate, and how to avoid production failures in modern toolchains.
Most TypeScript 6.0 upgrade failures stem from misconfigured module resolution. Teams migrate the language features, run the compiler, see green checkmarks, then watch imports fail in production because the bundler and TypeScript now interpret paths differently.
The root cause is moduleResolution: "bundler" — a new setting that fundamentally changes how TypeScript resolves imports. Unlike previous modes that mimicked Node.js behavior, bundler mode assumes a build tool like Vite, esbuild, or Webpack will handle resolution. This sounds convenient until the compiler accepts import paths your bundler rejects, or worse, accepts paths that silently resolve to the wrong files.
%% alt: problem diagram showing TypeScript accepting imports the bundler later rejects
flowchart LR
A("write import './utils'") --> B("tsc checks with bundler mode")
B --> C("compiler accepts the import")
C --> D("bundler fails: module not found")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The solution is understanding what bundler mode actually does, when it matches your toolchain, and which breaking changes require explicit fixes. TypeScript 6.0 makes bundler mode the recommended default for most projects, but the migration path from node16 or nodenext is not automatic.
%% alt: solution diagram showing aligned compiler and bundler resolution
flowchart LR
A("write import './utils'") --> B("tsc checks with bundler mode")
B --> C("compiler enforces file extensions")
C --> D("bundler resolves correctly")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This distinction is critical. The failure mode here is subtle but expensive: code that type-checks can still break in production if the resolution strategies diverge. The following sections break down exactly what bundler mode changes, when to use it, and how to migrate without downtime.
Key Takeaways
- moduleResolution: "bundler" assumes a build tool handles imports, allowing extensionless imports and package.json "exports" fields that Node.js modes reject.
- Breaking change: file extensions in import paths become optional, but declaration emit (
.d.tsfiles) still requires explicit extensions unless usingnoEmit: true. - Migration risk: switching from
node16ornodenextto bundler can silently break imports if your bundler does not match TypeScript's new assumptions. - Monorepo caveat: bundler mode works with workspace protocols, but subpath imports require
package.json"exports" mappings or the compiler will fail. - Decision rule: use bundler mode only when your build tool (Vite, esbuild, Webpack) controls all resolution—otherwise stick with
nodenextfor Node.js environments.
What moduleResolution: bundler Actually Does (And Why It Exists)
Bundler mode exists because modern build tools do not follow Node.js resolution rules. Tools like Vite and esbuild resolve import './utils' by checking multiple extensions (utils.ts, utils.tsx, utils.js) and support package.json "exports" fields that Node.js runtimes ignore in older modes.
Before TypeScript 6.0, teams used moduleResolution: "node" or "node16", which forced developers to write imports as if Node.js would execute them directly. This created friction: the TypeScript compiler required .js extensions in imports even though source files were .ts, and bundlers stripped or transformed those extensions anyway.
Bundler mode removes that friction by aligning TypeScript's resolution with what modern bundlers actually do. The compiler now allows extensionless imports, assumes the bundler will resolve them, and validates against package.json "exports" fields directly.
The implication here is that bundler mode is not a universal upgrade—it only makes sense when a bundler controls your module resolution. If you run TypeScript output directly in Node.js without a build step, bundler mode will produce imports Node.js cannot resolve.
%% alt: concept diagram showing bundler mode resolution flow
flowchart TD
A("import statement in source") --> B("TypeScript compiler checks resolution")
B --> C{"bundler mode enabled?"}
C -->|yes| D("allow extensionless imports")
C -->|no| E("require explicit extensions")
D --> F("validate against package.json exports")
E --> G("follow Node.js resolution rules")
F --> H("emit declarations")
G --> H
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Bundler Mode vs nodenext vs node16: A Real-World Comparison
The three resolution modes differ in how they handle file extensions, package.json fields, and type-only imports. These differences create incompatibilities when migrating or sharing code between projects.
%% alt: comparison of bundler, nodenext, and node16 resolution modes
flowchart LR
subgraph bundler["bundler mode"]
B1("extensionless imports allowed")
B2("exports field required")
B3("assumes bundler resolves")
end
subgraph nodenext["nodenext mode"]
N1("extensions required in ESM")
N2("exports field required")
N3("follows Node.js 18+ rules")
end
style B2 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style N3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
In bundler mode, import { helper } from './utils' compiles successfully if utils.ts exists. The compiler assumes the bundler will append the correct extension. In nodenext mode, the same import fails unless you write './utils.js' even though the source file is utils.ts. This forces developers to mentally translate file extensions during development.
Bundler mode also validates package.json "exports" fields but does not require them for relative imports. This means import 'lodash/map' works if lodash defines an "exports" mapping, but fails if the package only provides a "main" field. In contrast, node16 falls back to "main" when "exports" is missing, creating silent behavior differences between modes.
The critical difference is declaration emit. When TypeScript generates .d.ts files, bundler mode still emits relative imports without extensions unless the source explicitly includes them. This breaks consumers who use nodenext or Node.js directly, because .d.ts files with extensionless imports are invalid in strict ESM environments.
Breaking Changes: File Extensions, Import Paths, and Declaration Emit
Switching to bundler mode breaks code in three specific ways: extensionless imports that previously failed now pass, imports with explicit extensions may resolve differently, and emitted declarations can become invalid for consumers.
The first breakage happens when developers add file extensions to work around nodenext restrictions. Consider this code that compiles under nodenext:
// src/utils.ts
export function format(value: number): string {
return value.toFixed(2);
}
// src/index.ts (nodenext mode)
import { format } from './utils.js'; // extension required
console.log(format(123.456));Under bundler mode, both './utils' and './utils.js' work. But if the bundler is configured to prioritize .ts files and a utils.ts and utils.js both exist, the explicit .js import might resolve to the wrong file. This happens in monorepos where compiled outputs sit alongside source files.
The second breakage is declaration emit. When declaration: true is set, TypeScript generates .d.ts files. In bundler mode, these declarations preserve the exact import paths from source:
// src/index.ts (bundler mode)
import { format } from './utils';
export { format };Emits:
// dist/index.d.ts
export { format } from './utils';This declaration file is invalid in Node.js ESM environments because the import lacks an extension. Consumers using nodenext will see module resolution errors at runtime. The fix is either using noEmit: true (no declarations) or manually adding extensions in source code, defeating the purpose of bundler mode's flexibility.
The third breakage involves package.json "exports" validation. Bundler mode strictly enforces "exports" mappings, so packages that worked under node16 by exposing their entire dist/ folder now fail if they do not define subpath exports. This breaks libraries that have not updated their package.json for ESM compatibility.
When to Use bundler Mode (And When to Avoid It)
Use bundler mode when a build tool like Vite, esbuild, or Webpack fully controls your module resolution and you never run TypeScript output directly in Node.js. This includes most frontend applications, serverless functions processed by bundlers, and libraries that publish only bundled artifacts.
%% alt: decision flow for choosing bundler mode
flowchart LR
A("project type") --> B{"bundler handles all resolution?"}
B -->|yes| C("use bundler mode")
B -->|no| D{"Node.js ESM target?"}
D -->|yes| E("use nodenext mode")
D -->|no| F("use node16 mode")
C --> G("enable noEmit or fix declaration paths")
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Avoid bundler mode when publishing packages intended for direct consumption in Node.js, or when emitting declaration files that downstream consumers will import. The mismatch between bundler-style imports in .d.ts files and Node.js runtime expectations creates silent breakages for library users.
Also avoid bundler mode in dual-package scenarios where the same codebase targets both bundled browser environments and unbundled Node.js environments. The resolution rules are incompatible, and trying to support both with a single tsconfig creates more problems than it solves.
The practical rule is: if your deployment artifact is a bundle (a single .js file or a few chunks), use bundler mode. If your deployment artifact is unbundled TypeScript output running in Node.js, use nodenext. If you are migrating a large codebase and unsure, start with nodenext and measure the developer friction before switching.
Migrating an Existing Project: Step-by-Step tsconfig Changes
Migrating to bundler mode requires updating tsconfig.json, auditing import paths, and testing the build pipeline. The migration fails if done out of order because the compiler will accept invalid paths that later break.
%% alt: migration flow from nodenext to bundler mode
flowchart LR
A("audit current imports") --> B("update tsconfig.json moduleResolution")
B --> C("run tsc --noEmit")
C --> D{"any errors?"}
D -->|yes| E("fix import paths")
D -->|no| F("test bundler build")
E --> C
F --> G{"runtime errors?"}
G -->|yes| H("align bundler config")
G -->|no| I("migration complete")
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style H stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Start by running a full type-check with the current configuration to establish a baseline. Then update tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "bundler",
"module": "esnext",
"target": "esnext",
"allowImportingTsExtensions": true,
"noEmit": true
}
}The allowImportingTsExtensions flag permits import './utils.ts' in source code, which bundler mode needs if you want to reference TypeScript files directly. The noEmit: true flag avoids declaration emit problems—set this unless you are publishing a library.
Next, run tsc --noEmit to see which imports now fail. Common errors include:
- Packages without "exports" fields that previously resolved via "main"
- Subpath imports (
import '#internal/helper') missing frompackage.json"exports" - Relative imports with wrong extensions (e.g.,
'.js'when only.tsexists)
Fix these by adding "exports" mappings to package.json or updating import paths. Then test the bundler build. If the bundler uses a different resolution algorithm (e.g., Webpack's resolve.extensions includes .json but TypeScript does not), you will see runtime errors for imports TypeScript approved.
The final step is validating declarations if you cannot use noEmit: true. Generate .d.ts files and attempt to import them from a test project using nodenext mode. If imports fail, you must either switch back to nodenext or manually add .js extensions to all relative imports in source code.
Edge Cases: Monorepos, Subpath Imports, and Type-Only Imports
Monorepos expose three edge cases where bundler mode behaves unexpectedly: workspace protocol resolution, subpath imports without "exports" mappings, and type-only imports from packages that do not emit declarations.
Workspace protocols ("workspace:*" in package.json dependencies) work in bundler mode because TypeScript resolves them through the package manager's symlink structure. But if the workspace package lacks a "name" field or "exports" mapping, imports fail even though the bundler can resolve them.
Subpath imports (import '#internal/helper') require explicit "exports" mappings in the root package.json. Bundler mode does not fall back to filesystem resolution for hash imports—if the mapping is missing, the compiler errors immediately. This is stricter than most bundlers, which will attempt a relative path lookup.
Type-only imports (import type { User } from './types') behave differently when the target file does not exist. In bundler mode, TypeScript allows type-only imports from non-existent paths because it assumes the bundler will strip them. This creates a hazard: if you later convert the type-only import to a value import, the compiler will still accept it even though the bundler cannot resolve it.
The mitigation for these edge cases is maintaining strict alignment between package.json "exports" and actual module structure. Use a tool like publint to validate that your package.json exports match filesystem reality, and never rely on TypeScript's leniency for type-only imports as a shortcut for broken module paths.
Frequently Asked Questions
Does bundler mode work with Jest or Vitest test runners?
Yes, but the test runner must support ESM and extensionless imports. Vitest works out of the box with bundler mode. Jest requires extensionsToTreatAsEsm and a custom resolver to match TypeScript's behavior.
Can I use bundler mode and nodenext mode in the same monorepo?
Yes, but each package needs its own tsconfig.json with the appropriate moduleResolution setting. Do not use extends to share a single moduleResolution setting across packages with different deployment targets.
What happens if my bundler configuration conflicts with bundler mode?
The build will fail at bundler time, not TypeScript time. TypeScript assumes the bundler will resolve imports its way—if the bundler's resolve.extensions or alias configuration diverges, you will see "module not found" errors after type-checking passes.
Do I need to change my import statements when migrating to bundler mode?
Only if you were using explicit .js extensions to satisfy nodenext mode. In bundler mode, extensionless imports are preferred. However, if you emit declarations, you may need to keep explicit extensions to avoid breaking consumers.
Does bundler mode support dynamic imports and import() expressions?
Yes, import() expressions work identically to static imports in bundler mode. The same resolution rules apply, and the compiler assumes the bundler will handle code splitting.
Conclusion: Future-Proofing Your TypeScript Configuration in 2026
TypeScript 6.0's bundler mode represents a fundamental shift in how the compiler thinks about module resolution. Teams that align their tsconfig with their build tool's actual behavior will see fewer runtime surprises and less developer friction. Teams that blindly enable bundler mode without understanding the tradeoffs will ship broken imports.
The decision matrix is straightforward: use bundler mode when a bundler controls all resolution, use nodenext for Node.js-first projects, and never mix the two in the same output artifact. The migration path requires deliberate testing and validation, not just a tsconfig change.
That covers the essential patterns for TypeScript 6.0 module resolution. Apply these in production and the difference will be immediate—either in time saved debugging module errors, or in production incidents avoided by catching resolution mismatches during type-checking instead of at runtime.