2025-11-15 - TypeScript Lost My Types: How moduleResolution: "bundler" Broke Strict Type Checking in a Monorepo
The Problem
I was working on a monorepo with a shared UI package using class-variance-authority (CVA) for variant-based component styling. Everything seemed fine-the build passed, the components worked-but TypeScript wasn’t catching obvious type errors.
// This should error, but TypeScript was silent! 😱
<Button preset="xxx" size="sm" text="Small" />
// "xxx" is not a valid preset value
Even worse, IntelliSense wasn’t working. When I typed preset=", instead of seeing valid options like "primary" | "secondary" | "link", I just saw string. Something was fundamentally broken with the type system.
The Setup
Here’s the component structure:
// packages/ui/src/components/base/button/index.tsx
import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"
const buttonVariants = cva("base-styles", {
variants: {
preset: {
primary: "...",
secondary: "...",
link: "...",
// ... more variants
},
size: { sm: "...", md: "...", lg: "...", xl: "..." },
},
defaultVariants: { preset: "primary", size: "md" },
})
type ButtonVariants = VariantProps<typeof buttonVariants>
type BaseButtonProps =
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> &
Omit<ButtonVariants, "size"> & {
size?: ButtonSize
loading?: boolean
}
// ... TextButtonProps, ChildrenButtonProps, IconButtonProps
type ButtonProps = TextButtonProps | ChildrenButtonProps | IconButtonProps
The package was built with Rollup, and the generated .d.ts files looked perfect. Yet when consuming it from the Next.js app:
// apps/admin/src/app/page.tsx
import { Button } from "@mbhq/ui"
// No error, no IntelliSense 😢
;<Button preset="literally-anything" size="sm" text="Test" />
The Investigation
Step 1: Is CVA working?
I created a test within the UI package itself:
// packages/ui/src/test.ts
const testVariants = cva("base", {
variants: { color: { red: "...", blue: "..." } },
})
type TestVariants = VariantProps<typeof testVariants>
const test: TestVariants = {
color: "green", // ✅ Error! Type '"green"' is not assignable to type '"red" | "blue"'
}
CVA was working perfectly! The types were correct in the package itself.
Step 2: Is the generated .d.ts file correct?
// packages/ui/dist/src/components/base/button/index.d.ts
type ButtonVariants = VariantProps<typeof buttonVariants>
type BaseButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> &
Omit<ButtonVariants, "size"> & {
size?: ButtonSize
loading?: boolean
}
// ... looks good! ✅
The types looked perfect.
Step 3: Can I extract the types manually?
// apps/admin/src/test.ts
import type { ButtonProps } from "@mbhq/ui"
type ExtractPreset<T> = T extends { preset?: infer P } ? P : never
type TextProps = Extract<ButtonProps, { text: any }>
type PresetType = ExtractPreset<TextProps>
const test: PresetType = "xxx"
// ✅ Error! Type '"xxx"' is not assignable to type '"primary" | "secondary" | ...
Wait-TypeScript CAN see the correct types! But only when I extract them explicitly, not in JSX.
Step 4: The Smoking Gun
I created a type to inspect what TypeScript was actually seeing:
type Prettify<T> = { [K in keyof T]: T[K] } & {}
type ButtonPropsX = Prettify<ButtonProps>
When I hovered over ButtonPropsX in my IDE, I saw this horror:
type ButtonPropsX = {
[x: string]: any // 🚨 What?!
[x: number]: any // 🚨 Why?!
[x: symbol]: any // 🚨 No!!!
disabled?: boolean | undefined
form?: string | undefined
// ... rest of props
}
Those three index signatures were telling TypeScript: “This object can accept ANY property with ANY value!” This completely overrides any specific property types, making strict checking impossible.
The Root Cause
I compared the same code in a working monorepo. Same CVA version, same TypeScript version, same React types-but one crucial difference in tsconfig.json:
Broken repo:
{
"compilerOptions": {
"moduleResolution": "bundler" // 🔴
}
}
Working repo:
{
"compilerOptions": {
"moduleResolution": "node" // ✅
}
}
What is moduleResolution?
This TypeScript compiler option determines how module imports are resolved:
"node"- Classic Node.js resolution algorithm (default for many projects)"bundler"- Newer mode designed for modern bundlers (Webpack 5+, Vite, etc.)"node16"/"nodenext"- Modern Node.js with proper ESM/CJS support
Why "bundler" Broke Everything
The "bundler" mode was introduced in TypeScript 5.0 to better align with how modern bundlers handle module resolution. It’s supposed to be more flexible and forgiving-but that flexibility came with a cost.
When TypeScript resolves this type chain in "bundler" mode:
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> &
Omit<ButtonVariants, "size">
It somehow adds permissive index signatures to the resulting intersection type. This likely happens because:
- Cross-package type resolution behaves differently in
"bundler"mode - Complex type intersections (with
Omit,Pick, utility types) are handled less strictly - Workspace protocol (
"@mbhq/ui": "workspace:*") combined with bundler resolution creates edge cases
The index signatures essentially told TypeScript: “When in doubt, allow anything”-completely defeating the purpose of strict typing.
The Fix
Simply remove "moduleResolution": "bundler" from your tsconfig.json:
{
"extends": "@mbhq/tsconfig/nextjs.json",
"compilerOptions": {
"module": "esnext",
// "moduleResolution": "bundler", ❌ Remove this!
"plugins": [{ "name": "next" }]
// ... rest of config
}
}
Let it fall back to "moduleResolution": "node" from your base config.
Results
✅ IntelliSense works - Type preset=" and see all valid options
✅ Type errors appear - preset="xxx" now shows a red squiggle
✅ No index signatures - Prettify<ButtonProps> shows clean, specific types
✅ CI/CD type checking - tsc --noEmit catches errors properly
Key Takeaways
1. "bundler" mode is still immature
While moduleResolution: "bundler" is designed to better align with modern bundlers, it has rough edges-especially with:
- Monorepo workspace packages
- Complex type intersections and utility types
- Libraries that rely on precise type inference (like CVA)
2. Don’t blindly copy tsconfig settings
I had added "moduleResolution": "bundler" thinking it would be “more modern” for my webpack/Next.js setup. Wrong! The module resolution strategy affects type checking, not just runtime behavior.
3. Use type inspection utilities
The Prettify utility type was crucial for debugging:
type Prettify<T> = { [K in keyof T]: T[K] } & {}
It expands complex types so you can see exactly what TypeScript is inferring-including those sneaky index signatures.
4. Test type checking at the boundaries
When building shared packages:
- ✅ Test types work within the package
- ✅ Test types work when imported by consuming apps
- ✅ Test both IDE experience (IntelliSense) AND CLI (
tsc --noEmit)
5. Sometimes less is more
My initial instinct was to explicitly define types manually:
type ButtonPreset = "primary" | "secondary" | "link" | ...
But the real fix was removing configuration that was causing problems. Don’t fight the tools-fix the environment.
When Should You Use "bundler"?
According to the TypeScript 5.0 release notes, "bundler" is designed for projects where:
- A bundler is handling all imports (Webpack 5+, Vite, esbuild, etc.)
- You need features like
exportsfield inpackage.jsonto work correctly - You’re NOT using TypeScript’s own
--moduleoutput
For monorepos with shared packages and strict typing requirements, stick with "node" or "nodenext" until "bundler" matures.
Conclusion
What seemed like a bug in class-variance-authority or a problem with generated type definitions was actually a TypeScript configuration issue. The moduleResolution: "bundler" setting, while modern and well-intentioned, introduced permissive type behavior that broke strict type checking across package boundaries.
The lesson? Sometimes the newest features aren’t ready for production-especially when they interact with complex type systems in unexpected ways. When in doubt, use the battle-tested defaults.
Environment:
- TypeScript: 5.4.5
- class-variance-authority: 0.7.1
- Next.js: 14.1.0
- pnpm workspace monorepo
- React: 18.3.1
- @types/react: 18.3.24
Related Issues:
- TypeScript #54102 - moduleResolution bundler and index signatures
- TypeScript #51622 - bundler mode type resolution issues
Have you encountered similar type system mysteries? Drop a comment below!