Quick Tip: Creating Polymorphic Components in TypeScript

    Steve Kinney
    Share

    In this quick tip, excerpted from Unleashing the Power of TypeScript, Steve shows you how to use polymorphic components in TypeScript.

    In my article Extending the Properties of an HTML Element in TypeScript, I told you that, over the course of building out a large application, I tend to end up making a few wrappers around components. Box is a primitive wrapper around the basic block elements in HTML (such as <div>, <aside>, <section>, <article>, <main>, <head>, and so on). But just as we don’t want to lose all the semantic meaning we get from these tags, we also don’t need multiple variations of Box that are all basically the same. What we’d like to do is use Box but also be able to specify what it ought to be under the hood. A polymorphic component is a single adaptable component that can represent different semantic HTML elements, with TypeScript automatically adjusting to these changes.

    Here’s an overly simplified take on a Box element inspired by Styled Components.

    And here’s an example of a Box component from Paste, Twilio’s design system:

    <Box as="article" backgroundColor="colorBackgroundBody" padding="space60">
      Parent box on the hill side
      <Box
        backgroundColor="colorBackgroundSuccessWeakest"
        display="inline-block"
        padding="space40"
      >
        nested box 1 made out of ticky tacky
      </Box>
    </Box>
    

    Here’s a simple implementation that doesn’t have any pass through any of the props, like we did with Button and LabelledInputProps above:

    import { PropsWithChildren } from 'react';
    
    type BoxProps = PropsWithChildren<{
      as: 'div' | 'section' | 'article' | 'p';
    }>;
    
    const Box = ({ as, children }: BoxProps) => {
      const TagName = as || 'div';
      return <TagName>{children}</TagName>;
    };
    
    export default Box;
    

    We refine as to TagName, which is a valid component name in JSX. That works as far a React is concerned, but we also want to get TypeScript to adapt accordingly to the element we’re defining in the as prop:

    import { ComponentProps } from 'react';
    
    type BoxProps = ComponentProps<'div'> & {
      as: 'div' | 'section' | 'article' | 'p';
    };
    
    const Box = ({ as, children }: BoxProps) => {
      const TagName = as || 'div';
      return <TagName>{children}</TagName>;
    };
    
    export default Box;
    

    I honestly don’t even know if elements like <section> have any properties that a <div> doesn’t. While I’m sure I could look it up, none of us feel good about this implementation.

    But what’s that 'div' being passed in there and how does it work? If we look at the type definition for ComponentPropsWithRef, we see the following:

    type ComponentPropsWithRef<T extends ElementType> = T extends new (
      props: infer P,
    ) => Component<any, any>
      ? PropsWithoutRef<P> & RefAttributes<InstanceType<T>>
      : PropsWithRef<ComponentProps<T>>;
    

    We can ignore all of those ternaries. We’re interested in ElementType right now:

    type BoxProps = ComponentPropsWithRef<'div'> & {
      as: ElementType;
    };
    

    A autocompleted list of all of the element types

    Okay, that’s interesting, but what if we wanted the type argument we give to ComponentProps to be the same as … as?

    We could try something like this:

    import { ComponentProps, ElementType } from 'react';
    
    type BoxProps<E extends ElementType> = Omit<ComponentProps<E>, 'as'> & {
      as?: E;
    };
    
    const Box = <E extends ElementType = 'div'>({ as, ...props }: BoxProps<E>) => {
      const TagName = as || 'div';
      return <TagName {...props} />;
    };
    
    export default Box;
    

    Now, a Box component will adapt to whatever element type we pass in with the as prop.

    A Box with the props of a button

    We can now use our Box component wherever we might otherwise use a <div>:

    <Box as="section" className="flex place-content-between w-full">
      <Button className="button" onClick={decrement}>
        Decrement
      </Button>
      <Button onClick={reset}>Reset</Button>
      <Button onClick={increment}>Increment</Button>
    </Box>
    

    You can see the final result on the polymorphic branch of the GitHub repo for this tutorial.

    This article is excerpted from Unleashing the Power of TypeScript, available on SitePoint Premium and from ebook retailers.