Type-safe polymorphic React components
A polymorphic component is a design pattern that allows you to customize the HTML tag used by a React component via props:
<Typography as='label'>foo</Typography>
Typography
is polymorphic because the generated HTML will be an actual HTML
label
tag:
<label>foo</label>
This is easy to do if we’re just using JavaScript and do not care about type-safety:
It gets at lot more challenging if we want the component to be strongly-typed.
For instance, say we don’t want to allow the consumer to pass props that
doesn’t make sense for a specific HTML element — a span
HTML element is not
supposed to be passed a for
attribute:
// This should be fine<Typography as="label" htmlFor='' />// @ts-expect-error This shouldn't: htmlFor is not allowed in span elements<Typography as="span" htmlFor="" />
So let’s start typing this component.
Type-safe as
prop
The as
prop should only ever receive HTML tags like span
, div
etc.
Arbitrary string like foo
should break the build:
<Typography as='foo' />
What should be a type that accepts only valid HTML tags? Fortunately
@types/react
already provides us with such a type: React.ElementType
:
type TypographyProps = { as ?: React .ElementType } const Typography = ({ as : Component = 'span', ...rest }: TypographyProps ) => { return <Component {...rest } />} export default function App () { return <> <Typography as ='foo' />Type '"foo"' is not assignable to type 'ElementType<any> | undefined'.2322Type '"foo"' is not assignable to type 'ElementType<any> | undefined'. <Typography as ='div' /> </>}
Polymorphic props with generic types
Now, we need to properly type the acceptable props based on the as
prop. For
example, if as
is label
, we should be allowed to pass htmlFor
. By default
the component will be a span
element, so it should not accept htmlFor
.
To do this, we will need to use a generic type:
// TODO: implement PropsOftype PropsOf <T > = {} type TypographyProps <T extends React .ElementType > = { as ?: T } & PropsOf <T > const Typography = <T extends React .ElementType = 'span'>({ as , ...rest }: TypographyProps <T >) => { const Component = as ?? 'span' return <Component {...rest } />}
Let’s implement PropsOf
now. We need a type that receives a valid React
element such as button
or label
, and returns an object with the element’s
valid props.
Fortunately, there is a type already for this:
React.ComponentPropsWithoutRef
.
type PropsOf <T extends React .ElementType > = React .ComponentPropsWithoutRef <T > type TypographyProps <T extends React .ElementType = React .ElementType > = { as ?: T } & PropsOf <T > const Typography = <T extends React .ElementType = 'span'>({ as , ...rest }: TypographyProps <T >) => { const Component = as ?? 'span' return <Component {...rest } />} export default function App () { return ( <> {/* These should be ok */} <Typography data-foo ='bar' /> <Typography as ="label" htmlFor ="my-input" /> {/* This should not, span elements don't accept htmlFor */} <Typography htmlFor ="my-input" />Type '{ htmlFor: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'.
Property 'htmlFor' does not exist on type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'.2322Type '{ htmlFor: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'.
Property 'htmlFor' does not exist on type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'. {/* Neither this, div elements don't accept htmlFor */} <Typography as ="div" htmlFor ="my-input" />Type '{ as: "div"; htmlFor: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "div" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">'.
Property 'htmlFor' does not exist on type 'IntrinsicAttributes & { as?: "div" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">'.2322Type '{ as: "div"; htmlFor: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "div" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">'.
Property 'htmlFor' does not exist on type 'IntrinsicAttributes & { as?: "div" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">'. </> )}
Fortunately, the current implementation already handles custom components —
just pass it to as
and it should just work.
For instance, imagine we want to create a link using Link
from
react-router-dom
or next/link
.
// A mock Link componentfunction Link (props : { to : string }) { return <a href ={props .to } />} export default function App () { return ( <> <Typography as ={Link } to ='#' /> <Typography to ='#' />Type '{ to: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'.
Property 'to' does not exist on type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'.2322Type '{ to: string; }' is not assignable to type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'.
Property 'to' does not exist on type 'IntrinsicAttributes & { as?: "span" | undefined; } & Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">'. </> )}
Adding component own props type
Now, let’s say we want our Typography
component to have their own props, such
as a variant
prop. How should we do this while maintaining polymorphism?
We can do this by creating a new generic type, called PolymorphicProps
, that
accepts the element type and the component own props as argument.
With these arguments, we will use the element type to get all props accepted by that element’s type plus the component own props, while also handling potential conflicts between the two:
type PropsOf <T extends React .ElementType > = React .ComponentPropsWithoutRef <T > type PolymorphicProps < T extends React .ElementType = React .ElementType , TProps = {}> = { as ?: T } & TProps & Omit <PropsOf <T >, keyof TProps | 'as'> type BaseTypographyProps = { variant : 'heading' | 'paragraph'} type TypographyProps <T extends React .ElementType = 'span'> = PolymorphicProps < T , BaseTypographyProps > const Typography = <T extends React .ElementType = 'span'>({ as , ...rest }: TypographyProps <T >) => { const Component = as ?? 'span' // @ts-expect-error: FIXME this used to work in TypeScript 4.x but not anymore return <Component {...rest } />} export default function App () { return ( <> <Typography variant ="heading" data-foo ="bar" /> <Typography variant ="paragraph" as ="label" htmlFor ="my-input" /> </> )}
The way we handle potential conflicts between the two is by using Omit
type.
This will omit all props in the result of PropsOf
that are also in TProps
,
i.e. TProps
will take precedence.
Type-safe polymorphic refs
Now let’s make our component ref
props strongly-typed, conditional on the
as
prop. Here’s what I mean by this exactly:
import { useRef } from 'react'import { Typography } from './Typography' export default function App() { const buttonRef = useRef<HTMLButtonElement>(null) return ( <> {/* This should be fine */} <Typography as="button" ref={buttonRef} variant='paragraph' /> {/* This should be a type error... */} <Typography as="label" ref={buttonRef} variant='paragraph' /> </> )}
Our component does not accept a ref
prop yet. To do this, we need to change
the PropsOf
type to use React.ComponentPropsWithRef
instead of
React.ComponentPropsWithoutRef
:
type PropsOf<T extends React.ElementType> = React.ComponentPropsWithRef<T>
That’s the easy part but, of course, we didn’t forward any refs with that, we
actually need to use the
forwardRef
function.
Unfortunately, that’s where things start to get difficult — the forwardRef
doesn’t seem to be ergonomic enough to handle the polymorphic component case.
To illustrate this, let’s see how we’re supposed to type a non-polymorphic
component using the forwardRef
with TypeScript:
import { forwardRef , useRef } from 'react' type TypographyProps = { variant : 'heading' | 'paragraph'} const Typography = forwardRef <HTMLSpanElement , TypographyProps >( (props , ref ) => { return <span ref ={ref } {...props } /> })
As you can see, the function forwardRef
is itself a generic function, taking
two arguments: the ref
element’s type and the component’s props.
But this won’t help… to achieve what we want, we would need to pass as first
argument a type that is conditional on the as
prop value!
The problem doesn’t seem solvable unless we resort to type annotations/type
casting, as this article by Ben Ilegbodu
demonstrates.
So we essentially need to bypass the React.forwardRef
types and define the
component types from scratch.
type PropsOf <T extends React .ElementType > = React .ComponentPropsWithRef <T > type PolymorphicRef <T extends React .ElementType > = React .ComponentPropsWithRef <T >['ref'] type PolymorphicProps < T extends React .ElementType = React .ElementType , TProps = {}> = { as ?: T } & TProps & Omit <PropsOf <T >, keyof TProps | 'as' | 'ref'> & { ref ?: PolymorphicRef <T > } type BaseTypographyProps = { variant : 'heading' | 'paragraph'} type TypographyProps <T extends React .ElementType = 'span'> = PolymorphicProps < T , BaseTypographyProps > type TypographyComponent = <T extends React .ElementType = 'span'>( props : PolymorphicProps <T , TypographyProps <T >>) => JSX .Element | null const Typography : TypographyComponent = React .forwardRef ( <T extends React .ElementType = 'span'>( props : TypographyProps <T >, ref : PolymorphicRef <T > ) => { const { as , ...rest } = props const Component = as ?? 'span' return <Component ref ={ref } {...rest } /> }) import { useRef } from 'react' export default function App () { const buttonRef = useRef <HTMLButtonElement >(null) return ( <> <Typography as ="button" ref ={buttonRef } variant ="paragraph" /> <Typography as ="label" ref ={buttonRef } variant ="paragraph" />Type 'RefObject<HTMLButtonElement>' is not assignable to type '((instance: HTMLLabelElement | null) => void) | RefObject<HTMLLabelElement> | null | undefined'.
Type 'RefObject<HTMLButtonElement>' is not assignable to type 'RefObject<HTMLLabelElement>'.
Type 'HTMLButtonElement' is missing the following properties from type 'HTMLLabelElement': control, htmlFor2322Type 'RefObject<HTMLButtonElement>' is not assignable to type '((instance: HTMLLabelElement | null) => void) | RefObject<HTMLLabelElement> | null | undefined'.
Type 'RefObject<HTMLButtonElement>' is not assignable to type 'RefObject<HTMLLabelElement>'.
Type 'HTMLButtonElement' is missing the following properties from type 'HTMLLabelElement': control, htmlFor {/** * Apparently this is fine... because HTMLSpanElement implements the * HTMLElement interface, which the HTMLButtonElement inherits from. * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLSpanElement */} <Typography ref ={buttonRef } variant ="paragraph" /> </> )}