Build Strongly-Typed Polymorphic Components with React and Typescript
Hey! đ
In this detailed (and explanatory) guide, Iâll discuss how to build strongly typed Polymorphic React components with Typescript.
If you have no idea what that means, thatâs fine. Thatâs a decent pointer that this is just the right guide for you.
Ready?
Introduction
Letâs get some housekeeping out of the way.
Audience
I wrote this article for Typescript beginners / intermediate developers. If youâre a more advanced engineer, you may find reading the source code a faster way to get the same information (without my ramblings)
Free PDF download
If youâd rather read this as a well put together PDF, download it here.
At 55-pages, this makes for a decent weekend read.
What are Polymorphic Components?
When you learn React, one of the first concepts you learn is building reusable components.
The fine art of writing components once, and reusing them multiple times.
If you remember from React 101, the essential building blocks of classic reusable components are props and stateâwhere props are external, and state, internal.
The essential building blocks of reusability remain valid for this article. However, we will take advantage of props to allow the users of your component decide what âelementâ to eventually render.
OK, wait, donât get confused by that.
Consider the following React component:
const MyComponent = (props) => {
return (
<div>
This is an excellent component with props {JSON.stringify(props)}
</div>
);
};
Typically, your component would receive some props. Youâd go ahead to use these internally and then finally render some React element which gets translated to the corresponding DOM element. In this case, the div
element.
What if your component could take in props
to do more than just provide some data to be consumed within your component?
Instead of MyComponent
always rendering a div
, what if you could pass in a prop to determine the eventual element rendered to the DOM?
This is where polymorphic components come in.
By standard definition, the word Polymorphic means occurring in several forms. In the world of React components, a polymorphic component is a component that can be rendered with a different container element / node.
Even though the concept may sound alien to you (if youâre new to it in general), youâve likely already used a Polymorphic component.
Examples of Polymorphic components in the real-world
Open-source component libraries typically implement some sort of Polymorphic component.
Letâs consider some you may be familiar with.
I may not discuss your favourite open-source library, but please don't hesitate to take a look at your favourite OS library after you understand the examples here.
Chakra ui
Chakra UI has been my component library of choice for a decent number of production applications.
Itâs easy to use, has dark-theme support and is accessible by default (oh, not to forget the subtle component animations!).
So, how does Chakra UI
implement polymorphic props? The answer is by exposing an as
prop.
The as
prop is passed to a component to determine what eventual container element to render.
Using the as
prop is quite straightforward.
You pass it to the component, in this case, Box
:
<Box as="button"> Hello </Box>
And the component will render a button
element.
If you went ahead to change the as
prop to a h1
:
<Box as="h1"> Hello </Box>
Now, the Box
component renders a h1
:
Thatâs a polymorphic component at work!
This component can be rendered to entirely unique elements, all by passing down a single prop.
Material UIâs component prop
Material UI in most cases needs no introduction. Itâs been a staple of component libraries for years now. Itâs a robust component library with a mature user base.
Similar to chakra UI, material UI allows for a polymorphic prop called component
â it obviously doesnât matter what you choose to call your polymorphic prop.
Its usage is just as similar. You pass it to a component, stating what element or custom component youâd like to render.
Enough talking, hereâs an example from the official docs:
<List component="nav">
<ListItem button>
<ListItemText primary="Trash" />
</ListItem>
</List>
List
is passed a component prop of nav
, and so when this is rendered, itâll render a nav
container element.
Another user may use the same component, but not as a navigation. They may just want to render a to-do list:
<List component="ol">
...
</List>
And in this case, List
will render an ordered list ol
element.
Talk about flexibility! See a summary of the use cases (PDF) for polymorphic components.
As youâll come to see in the following sections of this article, polymorphic components are powerful. Apart from just accepting a prop of an element type, they can also accept custom components as props.
This will be discussed in a coming section of this article. For now, letâs get you building your first Polymorphic component!
Build your first Polymorphic component
Contrary to what you may think, building your first Polymorphic component is quite straightforward.
Hereâs a basic implementation:
const MyComponent = ({ as, children }) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
What to note here is the polymorphic prop as
â similar to chakra UIâs. This is the exposed prop to control the render element of the Polymorphic component.
Secondly, note that the as
prop isnât rendered directly. The following would be wrong:
const MyComponent = ({ as, children }) => {
// wrong render below đ
return <as>{children}</as>;
};
When rendering an element type at runtime, you must first assign to a capitalised variable, and then render the capitalised variable.
Now you can go ahead and use this component as follows:
<MyComponent as="button">Hello Polymorphic!<MyComponent>
<MyComponent as="div">Hello Polymorphic!</MyComponent>
<MyComponent as="span">Hello Polymorphic!</MyComponent>
<MyComponent as="em">Hello Polymorphic!</MyComponent>
Note the different as
prop passed to the rendered components above.
The Problems with this simple implementation
The implementation in the previous section, while quite standard, has many demerits.
Letâs explore some of these.
1. The as prop can receive invalid HTML elements.
Presently, it is possible for a user to go ahead and write the following:
<MyComponent as="emmanuel">Hello Wrong Element</MyComponent>
Theas
prop passed here is emmanuel
. Emmanuel is obviously a wrong HTML element, but the browser also tries to render this element.
An ideal development experience is to show some kind of error during development. For example, a user may make a simple typo: divv
instead of div
â and would get no indication of whatâs wrong.
2. Wrong attributes can be passed for valid elements.
Consider the following component usage:
<MyComponent as="span" href="https://www.google.com">
Hello Wrong Attribute
</MyComponent>
A consumer can pass a span
element to the as
prop, and an href
prop as well.
This is technically invalid.
A span
element does not (and should not) take in an href
attribute. That is an invalid HTML syntax.
However, now, a consumer of the component we've built could go ahead to write this, and theyâll get no errors during development.
3. No attribute support!
Consider the simple implementation again:
The only props this component accepts are as
and children
, nothing else. Thereâs no attribute support for even valid as
element props, i.e., if as
were an anchor element a
, we should also support passing an href
to the component.
<MyComponent as="a" href="...">A link </MyComponent>
Even though href
is passed in the example above, the component implementation receives no other props. Only as
and children
are deconstructed.
Your initial thoughts may be to go ahead and spread every other prop passed to the component as follows:
const MyComponent = ({ as, children, ...rest }) => {
const Component = as || "span";
return <Component {...rest}>{children}</Component>;
};
It seems like a decent solution, but now it highlights the second problem mentioned above. Wrong attributes will now be passed down to the component as well.
Consider the following:
<MyComponent as="span" href="https://www.google.com">
Hello Wrong Attribute
</MyComponent>
And note the eventual rendered markup:
A span
with an href
is invalid HTML.
How do we resolve these concerns?
To be clear, thereâs no magic wand to wave here. However, weâre going to leverage Typescript to ensure you build strongly typed Polymorphic components.
Upon completion, developers using your component will avoid the runtime errors above and instead catch them during development or build timeâthanks to Typescript.
Why is this bad?
To recap, the current issues with our simple implementation is subpar because:
- It provides a terrible developer experience
- It is not type-safe. Bugs can (and will) creep in.
Welcome, Typescript
If youâre reading this, a prerequisite is you already know some Typescriptâat least the basics. If you have no clue what Typescript is, I strongly recommend giving this document a read first.
OK, weâve established a starting point: we will leverage Typescript to solve the concerns aforementioned. Essentially, we will leverage Typescript to build strongly typed Polymorphic components.
The first two requirements we will start off with include:
- The
as
prop should not receive invalid HTML element strings - Wrong attributes should not be passed for valid elements
In the following section, we will get started introducing Typescript to make our solution more robust, developer friendly and production worthy.
Introduction to Typescript Generics
If you have a solid grasp of Typescript generics, please feel free to skip this section. This only provides a brief introduction for readers who arenât as familiar with generics.
If youâre new to Typescript generics, they can come off as difficult, but once you get the hang of it, youâll see it for what it truly is: an arguably simple construct for parametizing your types.
So, what are generics?
A simple mental model to approach generics is to see them as special variables for your types. Where Javascript has variables, Typescript has generics (for types).
Letâs have a look at a classic example.
Below is an echo
function where v
represents any arbitrary value:
const echo = (v) => {
console.log(v)
return v
}
The echo
function takes in this value v
, logs it to the console, and then returns the same value to the caller. No input transformations carried out!
Now, we can go ahead and use this function on varying input types:
echo(1) // number
echo("hello world") // string
echo({}) // object
And this works perfectly!
Thereâs just one problem. We havenât typed this function at all.
Letâs sprinkle some typescript in here. đ§
Start off with a naive way to accept any input values v
by using the any
keyword:
const echo = (v: any) => {
console.log(v)
return v
}
It seems to work.
Youâll get no typescript errors when you do this. However, if you take a look at where you invoke this function, youâll notice one important thing. Youâve now lost every form of type safety.
This may not be clear now, but if you went ahead to perform an operation as follows:
const result = echo("hello world")
let failure = result.hi.me
Line 2 will fail with an error.
let failure = result.hi.me
result
is technically a string
because echo
will return the string hello world
, and "hello world".hi.me
will throw an error.
However, by typing v
as any
, result
is equally typed as any
. This is because echo
returns the same value. Typescript infers the return type as the same as v
. i.e., any
.
With result
being of type any
, you get no type safety here. Typescript cannot catch this error. This is one of the downsides of using any
.
OK, using any
here is a bad idea. Letâs avoid it.
What else could you possibly do?
Another solution will be to spell out exactly what types are acceptable by the echo
function, as follows:
const echo = (v: string | number | object) => {
console.log(v);
return v;
};
Essentially, you represent v
with a union type.
v
can either be a string
, a number
or an object
.
This works great.
Now, If you go ahead to wrongly access a property on the return type of echo
, youâll get an appropriate error. e.g.,
const result = echo("hi").hi
Youâll get the following error: Property 'hi' does not exist on type 'string | number | object'.
This seems perfect.
Weâve represented v
with a decent rage of acceptable values.
However, what if you wanted to accept more value types? Youâd have to keep adding more union types.
Is there a better way to handle this? e.g., by declaring some sort of variable type based on whatever the user passes to echo
?
For a start, let's replace the union type with an hypothetical type weâll call Value
:
const echo = (v: Value) => {
console.log(v);
return v;
};
Once you do this, youâll get the following Typescript error:
Cannot find name 'Value'.ts (2304)
This is expected.
However, hereâs the beauty of generics. We can go ahead to define this Value
type as a genericâsome sort of variable represented by the type of v
passed to echo
when invoked.
To complete this, weâll use angle braces just after the =
sign as follows:
const echo = <Value> (v: Value) => {
console.log(v);
return v;
};
If youâre coding along, youâll notice there are no more Typescript errors. Typescript understands this to be a generic. The Value
type is a generic.
But how does Typescript know what Value
is?
Well, this is where the variable form of a generic becomes evident.
Take a look at how echo
is invoked:
echo(1)
echo("hello world")
echo({})
The generic Value
will take on whatever the argument type passed into echo
at invocation time.
For example, with echo(1)
, the type of Value
will be the literal number 1
. For echo("hello world")
, the type of Value
will be the literal string hello world
Note how this changes based on the type of argument passed to echo
.
This is wonderful.
If you went ahead to perform any operations on the return type of echo
, youâll get all the type safety youâd expectâwithout specifically specifying a single type but by representing the input with a generic aka a variable type.
Constraining Generics
Having learned the basics of generics, thereâs one more concept to understand before we get back to leveraging Typescript in our polymorphic component solution.
Letâs consider a variant of the echo
function. Call this echoLength
:
const echoLength = <Value> (v: Value) => {
console.log(v.length);
return v.length;
};
Instead of echoing the input value v
, the function echoes the length
of the input value, i.e., v.length
.
If you wrote this code out as is, the Typescript compiler will yell with an error:
Property 'length' does not exist on type 'Value'.ts (2339)
This is quite an important error.
The echoLength
parameter, v
, is represented by the generic Value
- which in fact represents the type of the argument passed to the function.
However, within the body of the function, we are accessing the length
property of the variable parameter.
So, whatâs the problem here?
The issue is, not every input will have a length
property.
The generic Value
as it stands represents any argument type passed by the function caller, however, not every argument type will have a length
property.
Consider the following:
echoLength("hello world")
echoLength(2)
echoLength({})
echoLength("hello world")
will work as expected because a string has a length
property.
However, the other two examples will return undefined
. Numbers and objects donât have length
properties. Hence, the code within the function body isnât the most type safe.
Now, how do we fix this?
We need to be able to take in a generic, but we want to specify exactly what kind of generic is valid.
In more technical terms, we need to constrain the generic accepted by this function to be limited to types that have a length
property.
To accomplish this, we will leverage the extends
keyword.
Take a look:
const echoLength = <Value extends {length: number}> (v: Value) => {
console.log(v.length);
return v.length;
};
Now, when you declare the Value
generic, add extends {length: number}
to denote that the generic will be constrained to types which have a lenght
property.
If you go ahead to use echoLength
as before, you should now get a Typescript error when you pass in values without a length property, e.g.,
// these will yield a typescript error
echoLength(2)
echoLength({})
What weâve done here is constraining the Value
generic to a specific mould. Yes, we want variable types. But we only want those that fit this specific mould, i.e., that fit a certain type signature.
Lovely!
With these two concepts understood, weâll now head back to updating our polymorphic component solution to be a lot more type safe â starting with the initial requirements weâd set.
Making sure the as prop only receives valid HTML element strings
Hereâs our current solution:
const MyComponent = ({ as, children }) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
To make the next sections of this guide practical, weâll change the name of the component from MyComponent
to Text
i.e., assuming weâre building a polymorphic Text component.
Now, with your knowledge of generics, it becomes obvious that weâre better off representing as
with a generic type, i.e., a variable type based on whatever the user passes in.
Letâs go ahead and take the first step as follows:
export const Text = <C>({
as,
children,
}: {
as?: C;
children: React.ReactNode;
}) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
Note how the generic C
is defined and then passed on in the type definition for the prop as
.
However, if you wrote this seemingly perfect code, youâll have Typescript yelling out numerous errors with more squiggly red lines than youâd like đ¤ˇââď¸
Whatâs going on here is a flaw in the syntax for generics in .tsx
files. There are two ways to solve this.
1. Add a comma after the generic declaration.
This is the syntax for declaring multiple generics. Once you do this, the typescript compiler clearly understands your intent and the errors banished.
// note the comma after "C" below đ
export const Text = <C,>({
as,
children,
}: {
as?: C;
children: React.ReactNode;
}) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
2. Constrain the generic.
The second option is to constrain the generic as you see fit. For starters, you can just use the unknown
type as follows:
// note the extends keyword below đ
export const Text = <C extends unknown>({
as,
children,
}: {
as?: C;
children: React.ReactNode;
}) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
For now, Iâll stick to solution 2 because itâs closer to our final solution. In most cases, I use the multiple generic syntax (adding a comma).
OK, now what next?
With our current solution, we get another Typescript error:
JSX element type 'Component' does not have any construct or call signatures.ts(2604)
This is similar to the error we had when we worked with the echoLength
function. Just like accessing the length
property of an unknown variable type, the same may be said here.
Trying to render any generic type as a valid React component doesnât make sense.
We need to constrain the generic ONLY to fit the mould of a valid React element type.
To achieve this, weâll leverage the internal React type: React.ElementType
, and make sure the generic is constrained to fit that type:
// look just after the extends keyword đ
export const Text = <C extends React.ElementType>({
as,
children,
}: {
as?: C;
children: React.ReactNode;
}) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
Note that if youâre on an older version of React, you may have to import React as follows:
import React from 'react'
With this, we have no more errors!
Now, if you go ahead and use this component as follows, itâll work just fine:
<Text as="div">Hello Text world</Text>
However, If you pass an invalid as
prop, youâll now get an appropriate typescript error. Consider the example below:
<Text as="emmanuel">Hello Text world</Text>
And the error thrown:
Type '"emmanuel"' is not assignable to type 'ElementType<any> | undefined'.
This is excellent!
We now have a solution that doesnât accept gibberish for the as
prop and will also prevent against nasty typos, e.g., divv
instead of div
.
This is a much better developer experience!
Handling valid component attributes
In solving this second use case, youâll come to appreciate how powerful generics truly are.
First, you do have to understand what weâre trying to accomplish here.
Once we receive a generic as
type, we want to make sure that the remaining props passed to our component are relevant, based on the as
prop.
So, for example, if a user passed in an as
prop of img
, weâd want href
to equally be a valid prop!
To give you a sense of how weâd accomplish this, take a look at the current state of our solution:
export const Text = <C extends React.ElementType>({
as,
children,
}: {
as?: C;
children: React.ReactNode;
}) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
The component props are now represented by:
{
as?: C;
children: React.ReactNode;
}
In pseudocode, what weâd like would be the following:
{
as?: C;
children: React.ReactNode;
} & {
...otherValidPropsBasedOnTheValueOfAs
}
This requirement is enough to leave one grasping at straws. We canât possibly write a function that determines appropriate types based on the value of as
, and itâs not smart to list out a union type manually.
Well, what if there was a provided type from React
that acted as a âfunctionâ thatâll return valid element types based on what you pass it?
Before introducing the solution, letâs have a bit of a refactor. Letâs pull out the props of the component into a separate type:
// đ See TextProps pulled out below
type TextProps<C extends React.ElementType> = {
as?: C;
children: React.ReactNode;
}
export const Text = <C extends React.ElementType>({
as,
children,
}: TextProps<C>) => { // đ see TextProps used
const Component = as || "span";
return <Component>{children}</Component>;
};
Whatâs important here is to note how the generic is passed on to TextProps<C>
. Similar to a function call in Javascript â but with angle braces.
Now, on to the solution.
The magic wand here is to leverage the React.ComponentPropsWithoutRef
type as shown below:
type TextProps<C extends React.ElementType> = {
as?: C;
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>; // đ look here
export const Text = <C extends React.ElementType>({
as,
children,
}: TextProps<C>) => {
const Component = as || "span";
return <Component>{children}</Component>;
};
Note that weâre introducing an intersection here. Essentially, weâre saying, the type of TextProps
is an object type containing as
and children
, and some other types represented by React.ComponentPropsWithoutRef
.
If you read the code, it perhaps becomes apparent whatâs going on here.
Based on the type of as
, represented by the generic C
, React.componentPropsWithoutRef
will return valid component props that correlates with the string attribute passed to the as
prop.
Thereâs one more significant point to note.
If you just started typing and rely on intellisense from your editor, youâd realise there are three variants of the React.ComponentProps...
type.
(I) React.ComponentProps
(Ii) React.ComponentPropsWithRef
(Iii) React.ComponentPropsWithoutRef
If you attempted to use the first, ComponentProps
, youâd see a relevant note that reads:
PreferComponentPropsWithRef
, if theref
is forwarded, orComponentPropsWithoutRef
when refs are not supported.
And this is precisely what weâve done.
For now, we will ignore the use case for supporting a ref
prop and stick to ComponentPropsWithoutRef
.
Now, letâs give the solution a try!
If you go ahead and use this component wrongly, e.g., passing a valid as
prop with other incompatible props, youâll get an error.
<Text as="div" href="www.google.com">Hello Text world</Text>
A value of div
is perfectly valid for the as
prop, but a div
should NOT have an href
attribute. Thatâs wrong, and righty caught by Typescript with the error: Property 'href' does not exist on type ...
This is great! Weâve got an even better (robust) solution.
Finally, make sure to pass on other props down to the rendered element:
type TextProps<C extends React.ElementType> = {
as?: C;
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;
export const Text = <C extends React.ElementType>({
as,
children,
...restProps, // đ look here
}: TextProps<C>) => {
const Component = as || "span";
// see restProps passed đ
return <Component {...restProps}>{children}</Component>;
};
Letâs keep going đđź
Handling default as attributes
Consider our current solution:
export const Text = <C extends React.ElementType>({
as,
children,
...restProps
}: TextProps<C>) => {
const Component = as || "span"; // đ look here
return <Component {...restProps}>{children}</Component>;
};
Particularly pay attention to where a default element is provided if the as
prop is omitted.
const Component = as || "span"
This is properly represented in the Javascript world, i.e., by implementation, if as
is optional, itâll default to a span
.
The question is, how does Typescript handle this case? i.e., when as
isnât passed? Are we equally passing a default type?
Well, the answer is no. But belowâs a practical example.
If you went ahead to use the Text
component as follows:
<Text>Hello Text world</Text>
Note that weâve passed no as
prop here. Will Typescript be aware of the valid props for this component?
Letâs go ahead and add an href
:
<Text href="https://www.google.com">Hello Text world</Text>
If you go ahead and do this, youâll get no errors.
Thatâs bad.
A span
should not receive an href
prop / attribute. While we default to a span in the implementation, Typescript is unaware of this default implementation. Letâs fix this with a simple, generic default assignment:
type TextProps<C extends React.ElementType> = {
as?: C;
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;
/**
* See default below. TS will treat the rendered element as a
span and provide typings accordingly
*/
export const Text = <C extends React.ElementType = "span">({
as,
children,
...restProps
}: TextProps<C>) => {
const Component = as || "span";
return <Component {...restProps}>{children}</Component>;
};
The important bit is highlighted below:
<C extends React.ElementType = "span">
And voilĂ ! The previous example we had should now throw an error, i.e., when you pass href
to the Text
component without an as
prop.
The error should read: Property 'href' does not exist on type ...
The component should be reusable with its props
Our current solution is much better than where we started. Give yourself a pat on the back for making it this far. However, it only gets more interesting from here.
The use case to cater to in this section is very applicable in the real world. Thereâs a high chance that if youâre building some sort of component, then that component will also take in some specific props, i.e., unique to the component.
Our current solution takes into consideration the as
, children
and other component prop based on the as
prop. However, what if we wanted this component to handle its props?
Letâs make this practical.
We will have the Text
component receive a color
prop. The color
here will be any of the rainbow colours, or black
.
We will go ahead and represent this as follows:
type Rainbow =
| "red"
| "orange"
| "yellow"
| "green"
| "blue"
| "indigo"
| "violet";
Next, we must define the color
prop in the TextProps
object as follows:
type TextProps<C extends React.ElementType> = {
as?: C;
color?: Rainbow | "black"; // đ look here
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;
Before we go ahead, letâs have a bit of a refactor. Let's represent the actual props of the Text
component by a Props
object, and specifically type only props specific to our component in the TextProps
object.
This will become obvious, as youâll see below:
// new "Props" type
type Props <C extends React.ElementType> = TextProps<C>
export const Text = <C extends React.ElementType = "span">({
as,
children,
...restProps,
}: Props<C>) => {
const Component = as || "span";
return <Component {...restProps}>{children}</Component>;
};
Now letâs clean up TextProps
:
// before
type TextProps<C extends React.ElementType> = {
as?: C;
color?: Rainbow | "black"; // đ look here
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;
// after
type TextProps<C extends React.ElementType> = {
as?: C;
color?: Rainbow | "black";
};
Now, TextProps
should just contain props specific to our Text
component: as
and color
.
We must now update the definition for Props
to include the types weâve removed from TextProps
i.e., children
and React.ComponentPropsWithoutRef<C>
For the children
prop, weâll take advantage of the React.PropsWithChildren
prop.
The way PropsWithChildren
works is easy to reason about. You pass it your component props, and itâll inject the children props definition for you.
Letâs leverage that below:
type Props <C extends React.ElementType> =
React.PropsWithChildren<TextProps<C>>
Note how we use the angle braces.
This is the syntax for passing on generics. Essentially, the React.PropsWithChildren
accepts your component props as a generic and augments it with the children
prop. Sweet!
For React.ComponentPropsWithoutRef<C>
, weâll just go ahead and leverage an intersection type here:
type Props <C extends React.ElementType> =
React.PropsWithChildren<TextProps<C>> &
React.ComponentPropsWithoutRef<C>
And hereâs the full current solution:
type Rainbow =
| "red"
| "orange"
| "yellow"
| "green"
| "blue"
| "indigo"
| "violet";
type TextProps<C extends React.ElementType> = {
as?: C;
color?: Rainbow | "black";
};
type Props <C extends React.ElementType> =
React.PropsWithChildren<TextProps<C>> &
React.ComponentPropsWithoutRef<C>
export const Text = <C extends React.ElementType = "span">({
as,
children,
}: Props<C>) => {
const Component = as || "span";
return <Component> {children} </Component>;
};
I know these can feel like a lot, but take a closer look, and itâll all make sense. Itâs really just putting together everything youâve learnt so far. Nothing should be particularly new.
All clear? Now, youâre becoming something of a pro!
Having done this necessary refactor, we can now continue our solution. What we have now actually works. Weâve explicitly typed the color
prop, and you may go ahead to use it as follows:
<Text color="violet">Hello world</Text>
Thereâs just one thing Iâm not particularly comfortable with.
color
turns out to also be a valid attribute for numerous HTML tags. This was the case pre-HTML5. So, if we removed color
from our type definition, itâll be accepted as any valid string.
See below:
type TextProps<C extends React.ElementType> = {
as?: C;
// remove color from the definition here
};
Now, if you go ahead to use Text
as before, itâs equally valid:
<Text color="violet">Hello world</Text>
The only difference here is how it is typed. color
is now represented by the following definition color?: string | undefined
Again, this is NOT a definition we wrote in our types!
This is a default HTML typing where color
is a valid attribute for most HTML elements. See this stack-overflow question for some more context.
Now, there are two ways to go here.
Firstly, you can keep our initial solution where we explicitly declared the color
prop:
type TextProps<C extends React.ElementType> = {
as?: C;
color?: Rainbow | "black"; // đ look here
};
Secondly, you can go ahead and arguably provide some more safety. To achieve this, you must realise where the previous default color
definition came from.
It came from the definition React.ComponentPropsWithoutRef<C>
- this is what adds other props based on what the type of as
is.
So, what we can do here is to explicitly remove any definition that exists in our component types from React.ComponentPropsWithoutRef<C>
This can be tough to understand before you see it in action, so letâs take it step by step.
React.ComponentPropsWithoutRef<C>
as stated earlier contains every other valid props based on the type of as
e.g., href
, color
, etc.
Where these types all have their definition, e.g., color?: string | undefined
etc.
It is possible that some values that exist in React.ComponentPropsWithoutRef<C>
also exist in our component props type definition.
In our case, color
exists in both!
Instead of relying on our color
definition to override whatâs coming from React.ComponentPropsWithoutRef<C>
, we will explicitly remove any type that also exist in our component types definition.
So, if any type exists in our component types definition, we will explicitly remove it from React.ComponentPropsWithoutRef<C>
.
How do we do this?
Well, hereâs what we had before:
type Props <C extends React.ElementType> =
React.PropsWithChildren<TextProps<C>> &
React.ComponentPropsWithoutRef<C>
Instead of just having an intersection type where we just add everything that comes from React.ComponentPropsWithoutRef<C>
, we will be more selective. We will use the Omit
and keyof
typescript utility types to perform some TS magic.
Take a look:
// before
type Props <C extends React.ElementType> =
React.PropsWithChildren<TextProps<C>> &
React.ComponentPropsWithoutRef<C>
// after
type Props <C extends React.ElementType> =
React.PropsWithChildren<TextProps<C>> &
Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>;
The important bit is this:
Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>;
If you donât know how Omit
and keyof
work, belowâs a quick summary.
Omit
takes in two generics. The first is an object type, and the second a union of types youâd like to âomitâ from the object type.
Hereâs my favourite example. Consider a Vowel
object type as follows:
type Vowels = {
a: 'a',
e: 'e',
i: 'i',
o: 'o',
u: 'u'
}
This is an object type of key and value.
What if I wanted to derive a new type from Vowels
called VowelsInOhans
.
Well, I do know that the name Ohans
contains two vowels o
and a
.
Instead of manually declaring these:
type VowelsInOhans = {
a: 'a',
o: 'o'
}
I can go ahead to leverage Omit
as follows:
type VowelsInOhans = Omit<Vowels, 'e' | 'i' | 'u'>
Omit
will âomitâ the e
, i
and u
keys from the object type Vowels
.
On the other hand, keyof
works as you would imagine. Think of Object.keys
in Javascript.
Given an object
type, keyof
will return a union type of the keys of the object. Phew! Thatâs a mouth full.
Hereâs an example:
type Vowels = {
a: 'a',
e: 'e',
i: 'i',
o: 'o',
u: 'u'
}
type Vowel = keyof Vowels
Now, Vowel
will be a union type of the keys of Vowels
i.e.,
type Vowel = 'a' | 'e' | 'i' | 'o' | 'u'
If you put these together and take a second look at our solution, itâll all come together nicely:
Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>;
keyof TextProps<C>
returns a union type of the keys of our component props. This is in turn passed to Omit
i.e., to omit them from React.ComponentPropsWithoutRef<C>
.
Sweet! đş
For completeness, letâs go ahead and actually pass the color
prop down to the rendered element:
export const Text = <C extends React.ElementType = "span">({
as,
color, // đ look here
children,
...restProps
}: Props<C>) => {
const Component = as || "span";
// đ compose an inline style object
const style = color ? { style: { color } } : {};
// đ pass the inline style to the rendered element
return (
<Component {...restProps} {...style}>
{children}
</Component>
);
};
Create a reusable utility for Polymorphic types
You must be proud of how come youâve come if youâve been following along.
Weâve got a solution that works â well.
However, now, letâs take it one step further.
The solution we have works great for our Text
component. However, what if youâd rather have a solution you can reuse on any component of your choosing?
This way, you can have a reusable solution for every use case.
How does that sound? Lovely, I bet!
Letâs get started.
First, hereâs the current complete solution with no annotations:
type Rainbow =
| "red"
| "orange"
| "yellow"
| "green"
| "blue"
| "indigo"
| "violet";
type TextProps<C extends React.ElementType> = {
as?: C;
color?: Rainbow | "black";
};
type Props<C extends React.ElementType> = React.PropsWithChildren<
TextProps<C>
> &
Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>;
export const Text = <C extends React.ElementType = "span">({
as,
color,
children,
...restProps
}: Props<C>) => {
const Component = as || "span";
const style = color ? { style: { color } } : {};
return (
<Component {...restProps} {...style}>
{children}
</Component>
);
};
Succinct and practical.
If we made this reusable, then it has to work for any component. This means removing the hardcoded TextProps
and representing that with a generic â so anyone can pass in whatever component props they need.
Currently, we represent our component props with the definition Props<C>
. Where C
represents the element type passed for the as
prop.
We will now change that to:
// before
Props<C>
// after
PolymorphicProps<C, TextProps>
PolymorphicProps
represents the utility type we will write shortly. However, note that this accepts two generic types. The second being the component props in question, i.e., TextProps
.
Letâs go ahead and define the PolymorphicProps
type:
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = {} // đ empty object for now
The definition above should be understandable. C
represents the element type passed in as
and Props
the actual component props, e.g., TextProps
.
Before going ahead, letâs go ahead and actually split the TextProps
we had before into the following:
type AsProp<C extends React.ElementType> = {
as?: C;
};
type TextProps = { color?: Rainbow | "black" };
So, weâve separated the AsProp
from the TextProps
. To be fair, they represent two different things. This is a nicer representation.
Now, letâs change the PolymorphicComponentProp
utility definition to include the as
prop, component props and children
prop as weâve done in the past:
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>>
Iâm sure you understand whatâs going on here.
We now have an intersection type of Props
(representing the component props) and AsProp
representing the as
prop, and these all passed into PropsWithChildren
to add the children
prop definition.
Excellent!
Now, we need to include the bit where we add the React.ComponentPropsWithoutRef<C>
definition. However, we must remember to omit props that exist in our component definition.
Letâs come up with a robust solution.
Write out a new type that just comprises the props weâd like to omit. Namely, the keys of the AsProp
and the component props as well.
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
Remember the keyof
utility type?
PropsToOmit
will now comprise a union type of the props we want to omit, which is, every prop of our component represented by P
and the actual polymorphic prop as
represented by AsProps
Iâm glad youâre still following.
Now, letâs put this all together nicely in the PolymorphicComponentProp
definition:
type AsProp<C extends React.ElementType> = {
as?: C;
};
// before
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>>
// after
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>,
PropsToOmit<C, Props>>;
Whatâs important here is weâve added the following definition:
Omit<React.ComponentPropsWithoutRef<C>,
PropsToOmit<C, Props>>;
This basically omits the right types from React.componentPropsWithoutRef
. Do you still remember how Omit works?
Simple as it may seem, you now have a solution you can reuse on multiple components across different projects!
Hereâs the complete implementation:
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
Now we can go ahead and use PolymorphicComponentProp
on our Text
component as follows:
export const Text = <C extends React.ElementType = "span">({
as,
color,
children,
// look here đ
}: PolymorphicComponentProp<C, TextProps>) => {
const Component = as || "span";
const style = color ? { style: { color } } : {};
return <Component {...style}>{children}</Component>;
};
How nice!
Now if you build another component, you can go ahead and type it like this:
PolymorphicComponentProp<C, MyNewComponentProps>
Do you hear that sound? Thatâs the sound of victory â youâve come so far!
The component should support refs
Do you remember every reference to React.ComponentPropsWithoutRef
so far? đ
Component props âŚ. without ref. Well, nowâs the time to put the ref in it!
This is the final and most complex part of our solution. Iâll need you to be patient here, but Iâll also do my best to explain every step in detail.
Letâs delve in.
First things first, do you remember how refs
in React work?
The most important concept here is the fact that you just donât pass ref
as a prop and expect it to be passed down into your component like every other prop.
The recommended way to handle refs
in your functional components is to use the forwardRef
function.
Letâs start off on a practical note.
If you go ahead and pass a ref
to our Text
component now, youâll get an error that reads Property 'ref' does not exist on type ...
// Create the ref object
const divRef = useRef<HTMLDivElement | null>(null);
...
// Pass the ref to the rendered Text component
<Text as="div" ref={divRef}>
Hello Text world
</Text>
This is expected.
Our first shot at supporting refs will be to use forwardRef
in the Text
component as shown below:
// before
export const Text = <C extends React.ElementType = "span">({
as,
color,
children,
}: PolymorphicComponentProp<C, TextProps>) => {
...
};
// after
import React from "react";
export const Text = React.forwardRef(
<C extends React.ElementType = "span">({
as,
color,
children,
}: PolymorphicComponentProp<C, TextProps>) => {
...
}
);
This is essentially just wrapping the previous code in React.forwardRef
, thatâs all.
Now, React.forwardRef
has the following signature:
React.forwardRef((props, ref) ... )
Essentially, the second argument received is the ref
object.
Letâs go ahead and handle that:
type PolymorphicRef<C extends React.ElementType> = unknown;
export const Text = React.forwardRef(
<C extends React.ElementType = "span">(
{ as, color, children }: PolymorphicComponentProp<C, TextProps>,
// đ look here
ref?: PolymorphicRef<C>
) => {
...
}
);
What weâve done here is added the second argument ref
and declared its type as PolymorphicRef
.
A type that just points to unknown
for now.
Also note that PolymorphicRef
takes in the generic C
. This is similar to previous solutions. The ref
object for a div
differs to that of a span
. So, we need to take into consideration the element type passed in the as
prop.
Letâs now point our attention to the PolymorphicRef
type.
I need you to think with me.
How can we get the ref
object type based on the as
prop?
Let me give you a clue: React.ComponentPropsWithRef
!
Note that this says with ref. Not without ref.
Essentially, if this were a bundle of keys (which in fact it is), itâll include all the relevant component props based on the element type, plus the ref object.
So now, if we know this object type contains the ref
key, we may as well get that ref type by doing the following:
// before
type PolymorphicRef<C extends React.ElementType> = unknown;
// after
type PolymorphicRef<C extends React.ElementType> =
React.ComponentPropsWithRef<C>["ref"];
Essentially, React.ComponentPropsWithRef<C>
returns an object type, e.g.,
{
ref: SomeRefDefinition,
// ... other keys,
color: string
href: string
// ... etc
}
To pick out just the ref
type, we then do this:
React.ComponentPropsWithRef<C>["ref"];
Note that the syntax is similar to the property accessor syntax in javascript, i.e., ["ref"]
. In Typescript, we call this is called Type indexing.
Quick quiz: Do you know why using âPickâ may not work well here, e.g.,Pick<React.ComponentPropsWithRef<C>, "ref">
?
You may use the comment section or tweet me your answers.
Now that weâve got the ref
prop typed, we can go ahead and pass that down to the rendered element:
export const Text = React.forwardRef(
<C extends React.ElementType = "span">(
{ as, color, children }: PolymorphicComponentProp<C, TextProps>,
ref?: PolymorphicRef<C>
) => {
//...
return (
<Component {...style} ref={ref}> // đ look here
{children}
</Component>
);
}
);
Weâve made decent progress! In fact, if you go ahead and check the usage of Text
like we did before, thereâll be no more errors:
// create the ref object
const divRef = useRef<HTMLDivElement | null>(null);
...
// pass ref to the rendered Text component
<Text as="div" ref={divRef}>
Hello Text world
</Text>
However, our solution still isnât as strongly typed as Iâd like.
Letâs go ahead and change the ref passed to the Text
as shown below:
// create a "button" ref object
const buttonRef = useRef<HTMLButtonElement | null>(null);
...
// pass a button ref to a "div". NB: as = "div"
<Text as="div" ref={buttonRef}>
Hello Text world
</Text>
Typescript should throw an error here, but it doesnât. Weâre creating a âbuttonâ ref, but passing that to a div
element.
If you take a look at the exact type, ref
it looks like this:
React.RefAttributes<unknown>.ref?: React.Ref<unknown>
Do you see the unknown
in there? Thatâs the sign of a weak typing. We should ideally have HTMLDivElement
in there, i.e., explicitly defining the ref object as a div
element ref.
Weâve got work to do.
Firstly, the types for the other props of the Text
component still reference the PolymorphicComponentProp
type.
Letâs change this to a new type called PolymorphicComponentPropWithRef
. You guessed right. This will just be a union of PolymorphicComponentProp
and the ref prop.
Here it is:
type PolymorphicComponentPropWithRef<
C extends React.ElementType,
Props = {}
> = PolymorphicComponentProp<C, Props> &
{ ref?: PolymorphicRef<C> };
Note that this is just a union of the previous PolymorphicComponentProp
and { ref?: PolymorphicRef<C> }
Now we need to change the props of the component to reference the newPolymorphicComponentPropWithRef
type:
// before
type TextProps = { color?: Rainbow | "black" };
export const Text = React.forwardRef(
<C extends React.ElementType = "span">(
{ as, color, children }: PolymorphicComponentProp<C, TextProps>,
ref?: PolymorphicRef<C>
) => {
...
}
);
// now
type TextProps<C extends React.ElementType> =
PolymorphicComponentPropWithRef<
C,
{ color?: Rainbow | "black" }
>;
export const Text = React.forwardRef(
<C extends React.ElementType = "span">(
{ as, color, children }: TextProps<C>, // đ look here
ref?: PolymorphicRef<C>
) => {
...
}
);
Now, weâve updated TextProps
to reference PolymorphicComponentPropWithRef
and thatâs now passed as the props for the Text
component.
Lovely!
Thereâs solely one final thing to do now. We will provide a type annotation for the Text
component. It goes similar to:
export const Text : TextComponent = ...
Where TextComponent
is the type annotation weâll write. Here it is:
type TextComponent = <C extends React.ElementType = "span">(
props: TextProps<C>
) => React.ReactElement | null;
This is essentially a functional component that takes in TextProps
and returns React.ReactElement | null
Where TextProps
is as defined earlier:
type TextProps<C extends React.ElementType> =
PolymorphicComponentPropWithRef<
C,
{ color?: Rainbow | "black" }
>;
With this, we now have a complete solution! Iâm going to share the complete solution now. It may seem daunting at first, but remember weâve worked line by line through everything you see here. Read it with that confidence.
import React from "react";
type Rainbow =
| "red"
| "orange"
| "yellow"
| "green"
| "blue"
| "indigo"
| "violet";
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
// This is the first reusable type utility we built
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
// This is a new type utitlity with ref!
type PolymorphicComponentPropWithRef<
C extends React.ElementType,
Props = {}
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
// This is the type for the "ref" only
type PolymorphicRef<C extends React.ElementType> =
React.ComponentPropsWithRef<C>["ref"];
/**
* This is the updated component props using PolymorphicComponentPropWithRef
*/
type TextProps<C extends React.ElementType> =
PolymorphicComponentPropWithRef<
C,
{ color?: Rainbow | "black" }
>;
/**
* This is the type used in the type annotation for the component
*/
type TextComponent = <C extends React.ElementType = "span">(
props: TextProps<C>
) => React.ReactElement | null;
export const Text: TextComponent = React.forwardRef(
<C extends React.ElementType = "span">(
{ as, color, children }: TextProps<C>,
ref?: PolymorphicRef<C>
) => {
const Component = as || "span";
const style = color ? { style: { color } } : {};
return (
<Component {...style} ref={ref}>
{children}
</Component>
);
}
);
And there you go!
You have successfully built a robust solution for handling Polymorphic components in React.
I know it wasnât an easy ride, but you did it.
Conclusion and Next Steps
Thanks for following along. Remember to star the official GitHub repository, where youâll find all the code for this guide.