Polymorphic Components with TypeScript

Let's start with a simplified version of what we had in the last example.

import * as React from "react";

type ButtonProps = {
  children: string;
  primary?: boolean;
  secondary?: boolean;
  destructive?: boolean;
};

const createClassNames = (classes: { [key: string]: boolean }): string => {
  let classNames = "";
  for (const [key, value] of Object.entries(classes)) {
    if (value) classNames += key + " ";
  }
  return classNames.trim();
};

function Button({
  children,
  primary = false,
  secondary = false,
  destructive = false,
}: ButtonProps) {
  const classNames = createClassNames({ primary, secondary, destructive });
  return <button className={classNames}>{children}</button>;
}

const Application = () => {
  return (
    <main>
      <Button primary>Primary</Button>
      <Button secondary>Secondary</Button>
      <Button destructive>Destructive</Button>
    </main>
  );
};

export default Application;

Augment the props with the fact that it could take an element. We're also going to move ButtonProps into ButtonOwnProps so that we can combine it later.

type ButtonOwnProps<E extends React.ElementType = React.ElementType> = {
  children: string;
  primary?: boolean;
  secondary?: boolean;
  destructive?: boolean;
  as?: E;
};

Omit the parts that we're overriding.

type ButtonProps<E extends React.ElementType> = ButtonOwnProps<E> &
  Omit<React.ComponentProps<E>, keyof ButtonOwnProps>;

What's happening here?

  • We have a generic, E.
  • We placed a constraint on E that it must be something that conforms to an HTML element.
  • Take our ButtonOwnProps that we just made.
  • Make a new type of whatever props that HTML element takes, but let us override it.
const defaultElement = "button";

function Button<E extends React.ElementType = typeof defaultElement>({
  children,
  primary = false,
  secondary = false,
  destructive = false,
  as,
}: ButtonProps<E>) {
  const classNames = createClassNames({ primary, secondary, destructive });
  const TagName = as || defaultElement;
  return <TagName className={classNames}>{children}</TagName>;
}

Where Are We Now?