Last week I wrote a post about conditional React prop types with TypeScript that generated a lot of discussion on Twitter. So I wanna continue the theme of how TypeScript levels up React, this time talking about creating polymorphic components with TypeScript.
It's quite possible that you've never heard of a polymorphic component before. It's a pattern for code reuse that's not nearly as popular as higher-order components, render props, or, of course, custom hooks. But it's probably the simplest one.
Let me quickly explain how a polymorphic component works. Let's say we have a shareable <Text>
component that allows the caller to configure the font
, size
and color
from a handful of options. Some sample calls would be:
<Text font="thin" size="10" color="gray-50">
Main Heading
</Text>
<Text font="heavy" size="5" color="gray-70">
Name
</Text>
<Text font="regular" size="4" color="gray-90">
Body text
</Text>
So we're reusing the same <Text>
component to display drastically different typographies. And if you care at all about semantic HTML, your first question might be, "well what HTML element are we using to render the text?" And this is exactly the value of polymorphic components. In the first example we'd likely want to render an <h1>
. The second should be a <label>
. And the third, a <p>
.
We can make <Text>
"polymorphic" by adding an as
prop to it that allows the caller to define the HTML tag to use:
<Text as="h1" font="thin" size="10" color="gray-50">
Main Heading
</Text>
<Text as="label" font="heavy" size="5" color="gray-70">
Name
</Text>
<Text as="p" font="regular" size="4" color="gray-90">
Body text
</Text>
And a basic implementation could look like:
export const Text = ({
as,
children,
font = 'regular',
size = '4',
color = 'gray-40',
...other,
}) => {
// imagine a helper that will generate a CSS class
// string based on the selected visual props
const classes = getClasses({font, size, color })
const Component = as || 'span'
return (
<Component {...other} className={classes}>
{children}
</Component>
)
}
In the first highlighted line, we default the as
prop to 'span'
(for a <span>
tag), and assign it to the variable Component
. The variable must be capitalized when we're rendering it, otherwise React will think it is the name of an HTML tag (<as>
). This is how JSX works.
The ...other
is also important because we spread it into <Component>
as well. This allows us to pass along additional attributes/props that we don't know about to the underlying element. An example is the htmlFor
prop needed for the <label>
:
<Text as="label" htmlFor="name-field" font="heavy" size="5" color="gray-70">
Name
</Text>
The problem is that the Text
implementation doesn't prevent me from passing in unsupported props like an href
for an <h1>
:
<Text
as="h1"
href="https://www.benmvp.com"
font="thin"
size="10"
color="gray-50"
>
Main Heading
</Text>
At runtime when React renders the code, it will complain in the console that href
doesn't exist on <h1>
. But I have to be paying attention to the console (and care enough to fix it). There's nothing preventing the caller from passing bad props.
Cue TypeScript.
TypeScript provides lots of benefit in React apps. But I've found its biggest benefit to be ensuring that our React component props are strongly typed. They can be statically checked instead of errors only discovered when the code has executed in the browser.
So in order for this to work, we need the as
prop to be typed to accept any valid HTML element, and then in addition to font
, size
, color
and children
, determine which props are valid based on that HTML element.
There are likely several ways to solve this, but this is how I do it:
import React from 'react'
interface Props<C extends React.ElementType> {
/**
* An override of the default HTML tag.
* Can also be another React component. 😉
*/
as?: C
children: React.ReactNode
color?: Color
font?: 'thin' | 'regular' | 'heavy'
size?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
}
type TextProps<C extends React.ElementType> = Props<C> &
Omit<React.ComponentPropsWithoutRef<C>, keyof Props<C>>
export const Text = <C extends React.ElementType = 'span'>({
as,
children,
font = 'regular',
size = '4',
color = 'gray-40',
...other
}: TextProps<C>) => {
const classes = getClasses({ font, size, color })
const Component = as || 'span'
return (
<Component {...other} className={classes}>
{children}
</Component>
)
}
This all works because of TypeScript generics, which is a whole huge post in and of itself that I really don't want to write (right now at least). But luckily Shu Uesugi has already written a fabulous post called TypeScript Generics for People Who Gave Up on Understanding Generics that explains TypeScript generics very well if you're still wanting to learn.
So the actual source code for Text
is basically the same. It's the typing of the props that makes it so that I can add htmlFor
when as="label"
, but it's an error if I set href="https://www.benmvp.com"
when as="h1"
. So let's break down how this all works chunk by chunk.
interface Props<C extends React.ElementType> {
as?: C
children: React.ReactNode
color?: Color
font?: 'thin' | 'regular' | 'heavy'
size?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
}
In addition to the children
, color
, font
, and size
props which are "normal" we have the optional as
prop which is of a generic type. I like to think of generics as "parameterized types." So the type of as
is the parameter C
, which is determined by the value the caller passes for as
. This makes our Props
generic or parameterized.
Now C
can't be any ol' type, or even any string
. We've restricted the type of C
to types that "extend" React.ElementType
. The definition for React.ElementType
is basically "any valid HTML element or another React component." So 'label'
can be a valid value for as
because it is extends React.ElementType
. But 'ben'
(sadly) is not a valid value.
type TextProps<C extends React.ElementType> = Props<C> &
Omit<React.ComponentPropsWithoutRef<C>, keyof Props<C>>
Here's where you get excited. Or you brain explodes. Or maybe both. It's basically what determines the additional HTML attributes/props that are valid based upon C
. Let's break it down further.
React.ComponentPropsWithoutRef<C>
First we grab all of the props defined for C
using React.ComponentPropsWithoutRef
. So if we pass as="label"
, this will include style
, className
, all of the ARIA tags, dozens of other props, as well as htmlFor
. As the name suggests, the component will not include the ref
prop for passing through refs.
If you want to support
ref
withReact.ComponentPropsWithRef
andforwardRef()
, read my follow-up post on Forwarding refs for a polymorphic React component in TypeScript.
Omit<React.ComponentPropsWithoutRef<C>, keyof Props<C>>
Next, using keyof
and the Omit<>
generic utility, we take those C
props and remove from them any props that are defined in Props
. So if Props
defined a style
prop or any other prop that already exists on C
, it will get removed. This is important because of:
Props<C> & Omit<React.ComponentPropsWithoutRef<C>, keyof Props<C>>
Basically we want to merge the properties of Props
and the properties of C
together. But if there are prop name collisions, TypeScript gets unhappy. Therefore we prevent the chance of a name collision by removing the duplicate props first.
type TextProps<C extends React.ElementType> = Props<C> &
Omit<React.ComponentPropsWithoutRef<C>, keyof Props<C>>
Now TextProps
has all of the valid props we want for the Text
component and it'll be based on the value we pass to the as
prop.
export const Text = <C extends React.ElementType = 'span'>({
as,
children,
font = 'regular',
size = '4',
color = 'gray-40',
...other
}: TextProps<C>) => {
const classes = getClasses({ font, size, color })
const Component = as || 'span'
return (
<Component {...other} className={classes}>
{children}
</Component>
)
}
Here is where we connect it altogether. In order for our Props
definition to be generic our component also needs to be generic. It's the same generic definition of C
except it also defaults the generic value to 'span'
. This is not required; I could leave off the default. But this way if the as
prop is left unspecified when <Text>
is rendered, we'll get the props valid for a <span>
instead of the props that are valid across all elements. This distinction would likely be more meaningful for more specific elements like <button>
or <a>
.
And the reason for all of this effort was so that ...other
would be strongly typed. It's no longer just a kitchen sink object, but has a specific type:
Pick<
TextProps<C>,
Exclude<
Exclude<
keyof React.ComponentPropsWithoutRef<C>,
"as" | "children" | "color" | "font" | "size"
>,
"as" | "children" | "color" | "font" | "size"
>
>
It's a bit convoluted because TextProps
is already a complex type, but it's basically "every prop in TextProps
except as
, children
, color
, font
and size
".
So these calls are fine:
<Text>hello</Text>
<Text style={{ position: 'relative' }}>layout</Text>
<Text as="label" htmlFor="name-field" font="heavy" size="5" color="gray-70">
Name
</Text>
But these generate errors:
<Text as="ben">Ben!</Text>
<Text
as="h1"
href="https://www.benmvp.com"
font="thin"
size="10"
color="gray-50"
>
Main Heading
</Text>
So we had to do some serious heavy lifting on the implementation side in TypeScript in order to provide a nice developer experience for callers of the component. I find that consistently to be the case with shareable React components written in TypeScript.
Curious what an error might look like? Well let's take the case where we specified href
for as="h1"
:
Type '{ children: string; as: "p"; href: string; }' is not assignable to type 'IntrinsicAttributes & Props<"p"> & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, "slot" | ... 253 more ... | "is"> & { ...; }, "slot" | ... 252 more ... | "is">'.
Property 'href' does not exist on type 'IntrinsicAttributes & Props<"p"> & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, "slot" | ... 253 more ... | "is"> & { ...; }, "slot" | ... 252 more ... | "is">'.
It's pretty gnarly to read because there are just so many props available on the type (... 253 more ...
😭), but I just pay attention to the Property 'href' does not exist
part and hope that's enough for me to figure out what's going on.
So I've been working on our component library at Stitch Fix basically for the entire year I've been here. And this was one of the first things I tried to get setup. I got the idea from the Material UI library (they use component
instead of as
).
It took me a really long time to get it working just right. It was the Omit<>
part that I was missing. Also, because we have many polymorphic components, I needed to abstract this polymorphic setup into easy-to-use helpers. After many iterations, here's what I came up with after borrowing bits and pieces from other open-source libraries.
import React from 'react'
// Source: https://github.com/emotion-js/emotion/blob/master/packages/styled-base/types/helper.d.ts
// A more precise version of just React.ComponentPropsWithoutRef on its own
export type PropsOf<
C extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>
> = JSX.LibraryManagedAttributes<C, React.ComponentPropsWithoutRef<C>>
type AsProp<C extends React.ElementType> = {
/**
* An override of the default HTML tag.
* Can also be another React component.
*/
as?: C
}
/**
* Allows for extending a set of props (`ExtendedProps`) by an overriding set of props
* (`OverrideProps`), ensuring that any duplicates are overridden by the overriding
* set of props.
*/
export type ExtendableProps<
ExtendedProps = {},
OverrideProps = {}
> = OverrideProps & Omit<ExtendedProps, keyof OverrideProps>
/**
* Allows for inheriting the props from the specified element type so that
* props like children, className & style work, as well as element-specific
* attributes like aria roles. The component (`C`) must be passed in.
*/
export type InheritableElementProps<
C extends React.ElementType,
Props = {}
> = ExtendableProps<PropsOf<C>, Props>
/**
* A more sophisticated version of `InheritableElementProps` where
* the passed in `as` prop will determine which props can be included
*/
export type PolymorphicComponentProps<
C extends React.ElementType,
Props = {}
> = InheritableElementProps<C, Props & AsProp<C>>
And now the Text
component can make use of our new helpers to define the TextProps
.
import React from 'react'
// The `Props` interface becomes a simple interface of the main props
interface Props {
children: React.ReactNode
color?: Color
font?: 'thin' | 'regular' | 'heavy'
size?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'
}
// `TextProps` now uses `PolymorphicComponentProps` to add the `as` prop
// and inherit its prop
type TextProps<C extends React.ElementType> = PolymorphicComponentProps<
C,
Props
>
export const Text = <C extends React.ElementType = 'span'>({
as,
children,
font = 'regular',
size = '4',
color = 'gray-40',
...other
}: TextProps<C>) => {
const classes = getClasses({ font, size, color })
const Component = as || 'span'
return (
<Component {...other} className={classes}>
{children}
</Component>
)
}
The definition of TextProps
is now much simpler because all of the complexity is abstracted away in PolymorphicComponentProps
(and its helper types). The Props
type is also simpler because it no longer needs to be generic in order to define the as
prop. PolymorphicComponentProps
is taking care of that too.
UPDATE: If you are interested in knowing how to properly type
forwardRef()
with polymorphic components in TypeScript, read Forwarding refs for a polymorphic React component in TypeScript.
Hopefully this can short-circuit your implementation. 😄 It's been super helpful for us in building our base components that need to be really flexible. If you've got any questions, comments or other feedback, feel free to reach out to me on Twitter at @benmvp.
Keep learning my friends. 🤓