Type-safe polymorphic React components - Phelipe Teles

Type-safe polymorphic React components

6 min.
View source code

A polymorphic component is a design pattern that allows you to customize the HTML tag used by a React component via props:

JavaScript
<Typography as='label'>foo</Typography>

Typography is polymorphic because the generated HTML will be an actual HTML label tag:

HTML
<label>foo</label>

This is easy to do if we’re just using JavaScript and do not care about type-safety:

JavaScript
const Typography = ({ as: Component = 'span' }) => {
return <Component />
}

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:

TypeScript
// 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:

JavaScript
<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:

TypeScript
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:

TypeScript
// TODO: implement PropsOf
type 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.

TypeScript
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.

TypeScript
// A mock Link component
function 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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

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.

TypeScript
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" />
</>
)
}