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:

  1. Cross-package type resolution behaves differently in "bundler" mode
  2. Complex type intersections (with Omit, Pick, utility types) are handled less strictly
  3. 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 exports field in package.json to work correctly
  • You’re NOT using TypeScript’s own --module output

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:


Have you encountered similar type system mysteries? Drop a comment below!