Polymorphic components with React and Typescript
What are Polymorphic components?
If you’ve ever tried any React component libraries like Material UI, Mantine UI or Chakra UI.
You might have used the as prop, which is available in most of the library components,
the general idea behind this prop is to allow the consumers of your library to pass a
custom element or another component that can be rendered instead of a hard-coded value.
There are plenty of confusing definitions out there when it comes to this topic, though the way I like to think about it is this:-
A polymorphic component is a design pattern that allows a component to act in more than one way depending on the UI requirements without breaking any accessibility or Type checking. So a
Buttonmight work as a Navigation-link or vice versa.
A key thing to note here is that it’s possible to build them without Typescript, however it is rudimentary and even incomplete without the type checking.
Let’s see an example of a polymorphic Text and Button component.
import Link from "react-router-dom"
function Section(){
return (
<section>
<Text as="h1" weight="bold">
I am a h1 heading
</Text>
<Text as="p" weight="medium">
I am a paragraph
</Text>
<Button as={Link} to="https://sidwebworks.com">
I am a button rendered as a Link component
</Button>
</section>
)
}
At first glance, the Text component looks super simple. All it takes is a tag name and
renders that element with the given content, so what gets rendered on the page is this
<section>
<h1 style="font-weight: bold;">
I am a h1 heading
</h1>
<p style="font-weight: medium;">
I am a paragraph
</p>
<a href="https://sidwebworks.com" className="btn">
I am a button rendered as a Link component
</a>
</section>
The Button component is where things start to become a little tricky, and this is where we see the true power of polymorphism.
button and a both are semantically very different, eg. a button does not accept an
href attribute whereas an a tag does.
Now let’s build one from scratch.
Project setup
You can use any frontend tooling like CRA, Snowpack or Vite
I will use Vite, to quickly create a new React + Typescript application with Vite I can do
# with NPM
npm create vite demo --template react-ts
# with Yarn
yarn create vite/app demo --template react-ts
Creating the Text component
Our Text component should be able do the following
- Accept an
asprop. - Type check props against attributes of the element passed in
asprop. - Accept a
weightprop to customise the font-weight of the element.
Here is how our basic Text component would look like
import React, { ComponentPropsWithoutRef } from "react"
type TextProps = __ComponentPropsWithoutRef<"span">__
const Text = ({ children, className, ...rest }: TextProps) => {
return (
<span className={className} {...rest}>
{children}
</span>
)
}
if it’s your first time using ComponentPropsWithoutRef utility type, no worries.
All is does is to take a React.ElementType eg: span and return an object type of all
the available attributes for that element.
Cool, the basic component stuff is done but! it’s still not Polymorphic.
Supporting the as prop
We will check if the as prop is provided, if it is then render
that as the Component
otherwise default to a regular span tag.
import React, { ComponentPropsWithoutRef, ElementType } from "react"
type TextProps = ComponentPropsWithoutRef<"span"> & { as?: ElementType }
const Text = ({ children, className, as, ...rest }: TextProps) => {
const __Component__ = __as__ || __"span"__
return (
<Component className={className} {...rest}>
{children}
</Component>
)
}
So now if we try it like this,
<Text as="a" href="http://sidwebworks">
Hello world
</Text>

Typescript screams at us with this weird error. Let me simplify it for you.
All it’s trying to say is href does not exists on TextProps which is quite right as
we passed span to the ComponentPropsWithoutRef utility and spans don’t have a href
attribute.
Let’s fix this.
Improving type support for as prop
The problem we now have is, when the as prop changes, the value passed to
ComponentPropsWithoutRef is still hard-coded.
import React, { ComponentPropsWithoutRef, ElementType } from "react"
type TextProps = ComponentPropsWithoutRef<__"span"__> & { as?: ElementType }
const Text = ({ children, className, as, ...rest }: TextProps) => {
const Component = as || "span"
return (
<Component className={className} {...rest}>
{children}
</Component>
)
}
So we need some way to pass a dynamic value in there which changes as we update our as prop value.
Luckily for us Typescript offers a feature
called Generics which can help us here.
A Generic type is essentially a type definition that can accept one or more type parameters, allowing it to work with different data types while maintaining type safety.
These type parameters behave like variables for types - they can be passed around, reused, or constrained, just like regular variables in code, but exist purely at the type level.
So let’s Genericify our component :)
In order to do that, it would be better to have a utility type like a
PolymorphicPropsWithoutRef which takes 2 generic types C (as prop) and P (custom
props)
We’re just making this utility so we can have some reusability in our code.
Writing a utility type
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
type AsProp<C extends ElementType> = {
as?: C
}
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>
Umm so what the hell does this type even do? Let’s break it down a bit.
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
type AsProp<C extends ElementType> = {
as?: C
}
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>
In the above highlighted code, like the name says we create a type for the as prop that takes in a generic C which extends the ElementType
Extending other types like this is called as Generic constrains meaning it will only accept a value that satifies that given constrain.
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
type AsProp<C extends ElementType> = {
as?: C
}
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>
Next, we create our main utility PolymorphicPropsWithoutRef, which again takes in 2 generics C for the ElementType and an optional type P for custom props which we default to as an empty object.
In this type we make use of 3 other utility types and compose them together.
These utilities are :-
PropsWithChildren: comes from React, adds thechildrenprop.AsProp: we made this! it adds theasprop.ComponentPropsWithoutRef: comes from React, adds the element’s HTML attributes.
Nice! let’s use it back in our Text Component.
import React, { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from "react"
type AsProp<C extends ElementType> = {
as?: C
}
type PolymorphicPropsWithoutRef<C extends ElementType, P = {}> = PropsWithChildren<P> & AsProp<C> & ComponentPropsWithoutRef<C>
type TextProps<C extends ElementType> = PolymorphicPropsWithoutRef<C>
const Text = <As extends ElementType = "span">({ children, className, as, ...rest }: TextProps<As>) => {
const Component = as || "span"
return (
<Component className={className} {...rest}>
{children}
</Component>
)
}
Works like a charm! Lets add our weight prop.
Adding a custom size prop
So we will have some default font weights object that our component will accept, and then use the keyof operator to extract a union type from that object.
Then we can pass it to our polymorphic utility type
const WEIGHTS = {
xs: '100',
sm: '200',
md: '400',
lg: '600',
};
type FontWeights = keyof typeof WEIGHTS
type TextProps<C extends ElementType> = PolymorphicPropsWithoutRef<C, {
weight: FontWeights
}>
Next we can merge the style object coming from the props and add an fontWeight key, which will be the weight prop’s value
const WEIGHTS = {
xs: '100',
sm: '200',
md: '400',
lg: '600',
};
type FontWeights = keyof typeof WEIGHTS
type TextProps<C extends ElementType> = PolymorphicPropsWithoutRef<C, {
weight: FontWeights
}>
const Text = <As extends ElementType = "span">({
children,
className,
as,
weight = "md",
style,
...rest
}: TextProps<As>) => {
const Component = as || "span";
const _style = { ...style, fontWeight: WEIGHTS[weight] };
return (
<Component className={className} style={_style} {...rest}>
{children}
</Component>
);
};
That’s it,
Now you can use these same techniques to create other polymorphic components like Button, Icon etc.