← Back to blog

Typing Polymorphic React Components in TypeScript

4 min read
#typescript#react#generics#advanced-types#components

I recently came across an interview question about advanced TypeScript patterns, specifically how to type a Button component that can also render as a link. These are the kinds of concepts that are easy to forget, especially if you don't write as much code by hand anymore because AI handles a lot of it.

That's why it's worth revisiting them from time to time. One of the best ways to check whether you truly understand a concept is to explain it to yourself and honestly ask whether someone else could understand that explanation. So here we are.

Everything optional

Everything is optional, so TypeScript can't infer whether this component should behave as a button or a link. Since there is no relationship between the props, invalid combinations are allowed.

type Props = {
  href?: string;
  target?: string;
  type?: "button" | "submit" | "reset";
  disabled?: boolean;
  onClick?: () => void;
};

function Button(props: Props) {
  if (props.href) {
    return <a {...props} />;
  }

  return <button {...props} />;
}

<Button href="#" disabled />; // ✅ No TypeScript error

Union types

TypeScript says:

"I have a value whose type is LinkProps | ButtonProps. I'll check whether it matches one of the union members."

type LinkProps = {
  href: string;
  target?: string;
  onClick?: () => void;
};

type ButtonProps = {
  type?: "button" | "submit" | "reset";
  disabled?: boolean;
  onClick?: () => void;
};

type Props = LinkProps | ButtonProps;

function Button(props: Props) {
  if ("href" in props) {
    return <a {...props} />;
  }

  return <button {...props} />;
}

<Button href="#" disabled />; // ❌ Property 'disabled' does not exist on type 'LinkProps'

Discriminated unions

Sometimes you don't want to infer the rendered element from the props. Instead, you want the user to choose it explicitly.

TypeScript says:

"I have a value whose type is LinkProps | ButtonProps. I'll use the as property to determine which one it is."

type LinkProps = {
  as: "a";
  href: string;
  target?: string;
  onClick?: () => void;
};

type ButtonProps = {
  as: "button";
  type?: "button" | "submit" | "reset";
  disabled?: boolean;
  onClick?: () => void;
};

type Props = LinkProps | ButtonProps;

function Button(props: Props) {
  if (props.as === "a") {
    return <a {...props} />;
  }

  return <button {...props} />;
}

<Button as="button" href="#" />; // ❌ Property 'href' does not exist on type 'ButtonProps'

Generic polymorphic components

In React 19+, the recommended utility type is React.ComponentProps<T>.

It gives you all the props supported by the element or component represented by T.

type Props<T extends React.ElementType> = {
  as?: T;
} & React.ComponentProps<T>;

function Button<T extends React.ElementType = "button">(props: Props<T>) {
  const { as, ...rest } = props;

  const Component = as ?? "button";

  return <Component {...rest} />;
}

Note: In production code you'll often see Omit<React.ComponentProps<T>, "as"> instead of React.ComponentProps<T>. This removes any existing as prop from the rendered component. Since HTML elements like "button" and "a" don't define an as prop, the simplified version used here is sufficient.

Now TypeScript automatically infers the correct props based on the value of as:

<Button as="a" href="#" /> // ✅
<Button as="button" disabled /> // ✅

<Button as="button" href="#" /> // ❌
<Button as="a" disabled /> // ❌

Note: If you're using React 18 or earlier, use React.ComponentPropsWithoutRef<T> instead. Before React 19, ref wasn't treated as a normal prop for function components, so excluding it was usually the correct choice.

React.ComponentProps<T> is equivalent to saying:

"Give me all the props that the element or component T accepts."

Unlike union types, there's no need to manually define every possible combination (LinkProps | ButtonProps | ...). The props are computed directly from the generic type T, making the component scalable to any HTML element or custom React component.