Right, so you’ve built a few components. A Button, a Card. You pass some strings, maybe a boolean for disabled. It feels good. Then the product manager waltzes in and says, “Great, but now we need a component that can either be a primary button, a secondary button, or a hyperlink that looks like a button. Oh, and if it’s a link, it needs an href and a target, but if it’s a button, it needs an onClick and a type.”

Your first instinct might be to reach for optional everything. Don’t. You’ll end up with a Frankenstein’s monster of props, and TypeScript will just shrug while you write bugs.

// 🚫 The "Please Don't Do This" approach
interface FrankensteinButtonProps {
  text: string;
  variant?: 'primary' | 'secondary';
  onClick?: () => void; // Optional if it's a link
  href?: string; // Optional if it's a button
  target?: '_blank' | '_self'; // What even is this if it's a button?
  type?: 'button' | 'submit'; // Meaningless for a link
}

Trying to use this is a nightmare. You can pass an onClick and an href, and TypeScript will happily let you, because technically all those fields are optional. Your logic becomes a mess of if (props.href) checks. We’re better than this.

The “Aha!” Moment: Discriminated Unions

This is where discriminated unions swoop in like a superhero with excellent type hygiene. The core idea is simple: we create a union type where each member has a common, literal-type property (the “discriminant”) that TypeScript can use to figure out exactly which member of the union we’re dealing with.

For our button/link component, the discriminant is what kind of thing it is.

// 1. Define the common base props. Every variant needs these.
interface BaseProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
}

// 2. Define the props for each specific variant.
interface ButtonProps extends BaseProps {
  as: 'button'; // The discriminant literal
  onClick: () => void;
  type?: 'button' | 'submit' | 'reset';
}

interface AnchorProps extends BaseProps {
  as: 'a'; // The discriminant literal
  href: string;
  target?: '_blank' | '_self' | '_parent' | '_top';
}

// 3. Create the union type.
type PolymorphicButtonProps = ButtonProps | AnchorProps;

See what we did there? The as property is our discriminator. It’s not a generic string; it’s the literal 'button' or the literal 'a'. This is the magic key.

Implementing the Component

Now, let’s build the component. This is where you see the payoff. Inside the component, TypeScript will use the discriminant to narrow the type of the props.

const PolymorphicButton = (props: PolymorphicButtonProps) => {
  // We destructure 'as' out first because we need it to tell TS what's up.
  const { as, variant = 'primary', children } = props;

  const className = `btn btn-${variant}`;

  // Using a switch statement on the discriminant is the cleanest way.
  switch (as) {
    case 'button':
      // Inside this block, TypeScript KNOWS props is ButtonProps!
      // It will demand an `onClick` and offer `type` as an option.
      // Try to access `href` here, and it'll throw a fit. Perfect.
      const { onClick, type = 'button' } = props;
      return (
        <button className={className} onClick={onClick} type={type}>
          {children}
        </button>
      );

    case 'a':
      // And here, it KNOWS it's AnchorProps. `href` is required, `target` is optional.
      // `onClick` doesn't exist here. Beautiful.
      const { href, target } = props;
      return (
        <a className={className} href={href} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined}>
          {children}
        </a>
      );
  }
};

Using It Without Losing Your Mind

The best part is the consumer experience. TypeScript now becomes your incredibly strict but brilliant assistant.

// ✅ Perfectly valid
<PolymorphicButton as="button" onClick={() => console.log('Clicked!')}>
  Click Me
</PolymorphicButton>

// ✅ Also valid
<PolymorphicButton as="a" href="https://example.com" target="_blank">
  Visit Example
</PolymorphicButton>

// ❌ Error: Property 'onClick' is missing... because you said it was a 'button'!
<PolymorphicButton as="button">
  Broken Button
</PolymorphicButton>

// ❌ Error: Property 'href' is missing... because you said it was an 'a'!
<PolymorphicButton as="a" onClick={() => console.log('huh?')}>
  Broken Link
</PolymorphicButton>

// ❌ Error: Type '"div"' is not assignable to type '"button" | "a"'
<PolymorphicButton as="div">
  What am I even doing?
</PolymorphicButton>

It guides the user to the correct combination of props every single time. It makes the component completely self-documenting and impossible to misuse. This isn’t just type safety; it’s developer experience gold.

The One “Gotcha” to Avoid

The most common mistake is putting the discriminant property inside the BaseProps as an optional property. This completely breaks the discrimination.

// 🚫 DON'T DO THIS
interface BaseProps {
  as?: 'button' | 'a'; // Optional discriminant in the base
  children: React.ReactNode;
}

interface ButtonProps extends BaseProps {
  // Now TS thinks ButtonProps can have as?: 'button' | 'a'
  onClick: () => void;
}
// The union becomes a messy, non-discriminatable blob.

Keep the discriminant required and on each member of the union. It’s the whole point. You’re forcing the consumer to make a choice up front, and that choice dictates everything else. It’s not a suggestion; it’s the law. And in this case, the law is logically consistent and on your side.