← Back to Blog

TypeScript Union Type Too Complex: A Typing Animation Bug

TypeScript
React
Next.js
Debugging

While implementing a typing animation component for my portfolio website, I ran into an interesting TypeScript compilation error that had me scratching my head for a bit. The error message was obviously cryptic at first, but understanding what caused it taught me something valuable about TypeScript's type system.

The Error

Here's what showed up in my build logs:

./components/magicui/typing-animation.tsx:55:16
Type error: Expression produces a union type that is too complex to represent.
  53 | 
  54 |   return (
> 55 |     <Component ref={ref} className={cn('inline-block', className)}>
     |                ^
  56 |       <motion.span

The build worked fine locally but failed when deploying to Netlify. Classic "works on my machine" situation.

The Problematic Code

I was building a reusable typing animation component that could render as any HTML element:

interface TypingAnimationProps {
  children: string;
  duration?: number;
  delay?: number;
  startOnView?: boolean;
  className?: string;
  as?: keyof JSX.IntrinsicElements; // This was the culprit
}

export function TypingAnimation({
  children,
  duration = 100,
  delay = 0,
  startOnView = false,
  className,
  as: Component = 'div',
}: TypingAnimationProps) {
  const ref = useRef(null);
  const isInView = useInView(ref);

  // Animation logic here...

  return (
    <Component ref={ref} className={cn('inline-block', className)}>
      {/* Content */}
    </Component>
  );
}

The idea was to make the component flexible—you could use it as a div, span, p, or any other HTML element by passing the as prop.

What Went Wrong?

The issue lies in how TypeScript handles the combination of dynamic component types and refs. When you use keyof JSX.IntrinsicElements, you are telling TypeScript that this component could be ANY HTML element; that is over 100 different types!

Each HTML element has its own specific ref type:

  • <div> uses HTMLDivElement
  • <span> uses HTMLSpanElement
  • <input> uses HTMLInputElement
  • And so on...

When TypeScript tries to create a union type for all possible ref combinations with all possible HTML elements, it creates a massive type union that exceeds TypeScript's complexity limits. Hence the error: "Expression produces a union type that is too complex to represent."

The Fix

The solution was straightforward: simplify the component by removing the dynamic element type:

interface TypingAnimationProps {
  children: string;
  duration?: number;
  delay?: number;
  startOnView?: boolean;
  className?: string;
  // Removed the 'as' prop
}

export function TypingAnimation({
  children,
  duration = 100,
  delay = 0,
  startOnView = false,
  className,
}: TypingAnimationProps) {
  const ref = useRef<HTMLDivElement>(null); // Explicitly typed ref
  const isInView = useInView(ref);

  // Animation logic...

  return (
    <div ref={ref} className={cn('inline-block', className)}>
      {/* Content */}
    </div>
  );
}

By hard-coding the element as a div and properly typing the ref as useRef<HTMLDivElement>(null), the type complexity disappeared and the build succeeded.

Alternative Solutions

If you really need the flexibility of different HTML elements, here are some alternatives:

  1. Limited element types: Instead of allowing any element, restrict to a few common ones:

    as?: 'div' | 'span' | 'p' | 'section';
    
  2. Separate components: Create specific variants like TypingAnimationDiv, TypingAnimationSpan, etc.

  3. Type assertion: Use type assertions to bypass the type checking (though this reduces type safety).

Lessons Learned

This bug taught me that TypeScript's type system, while powerful, has practical limits. When creating flexible components, it's important to balance flexibility with type complexity. Sometimes, a simpler solution that covers 95% of use cases is better than a complex one that tries to handle everything.

In my case, the typing animation component really only needed to be a div anyway, so the simpler solution was actually the better one.