Making your components extensible with TypeScript
I love working with TypeScript! At first, it's a bit of a struggle, but once you get used to it, it will definitely pay off! Most React packages have TypeScript definition (*.d.ts) files included these days, which makes working with these packages a joy. To summarize: if you want fewer bugs, and fewer time looking up which component accepts which props all day long, choose TypeScript! But enough with the pitching...
In this article I'm gonna focus on how to make your components extensible in TypeScript. By extensible I mean, extending your original component with styles, classnames, event handlers, ref's, etc., because you do not always know how your component is going to be used. In other words: extensible === anticipating the component to be extended.
Does this mean that all my components should be extensible? No, it certainly does not. In my experience, the components that could be classified as primitives are the ones that are most qualified to be extensible. Think input elements, button's, toggles, typographical elements, and so on.
Let's pick one of those primitive elements for the upcoming examples... a button. With each example I'm going to introduce a new use case and how to make it work with TypeScript. The implementation of this button is not that important. What is important, is the way how we 'talk' to the component from the outside. You know,... props!.
Mimic a button element
Let's start with the most basic use case possible: mimicking a button element. Our goal is to make the component behave exactly the same as a 'native' button element. The only difference is that <button />
will become <Button />
.
import * as React from "react";
const Button = React.forwardRef(function Button(
props: React.ComponentPropsWithoutRef<"button">,
ref: React.Ref<HTMLButtonElement>
) {
return <button ref={ref} {...props} />;
});
function Example() {
return <Button style={{ margin: 16 }}>Click me!</Button>;
}
As you can see we're utilizing ComponentPropsWithoutRef<"button">
to get all available props from the button
element. We also forward a ref
so we could use a reference to the native dom-element later on.
The <Button />
we have so far isn't really useful, is it? So, let's add some custom properties.
Adding custom properties
For the sake of simplicity, I'm going to introduce two props: id
and onClick
. We want the button to call our onClick
callback with the same id we've provided.
import * as React from "react";
type Props = { id: string; onClick: (id: string) => void;} & React.ComponentPropsWithoutRef<"button">;
const Button = React.forwardRef(function Button(
{ id, onClick, ...props }: Props, ref: React.Ref<HTMLButtonElement>
) {
return <button ref={ref} id={id} onClick={() => onClick(id)} {...props} />;
});
function Example() {
return (
<Button id="a" onClick={id => console.log(id)} style={{ margin: 16 }}> Click me!
</Button>
);
}
Great, we've made use of TypeScript's intersection types to combine the native button props with our custom props. We only got one problem...

TypeScript is unable to infer onClick
's first argument correctly, which results in an any
type. The reason is that our custom onClick
prop is clashing with the button's onClick
. We know, with common sense, that it's probably going to be called with id: string
, but TypeScript just isn't so sure about it. So we have 2 options:
- Help TypeScript a little bit, and type the
id
argument in theonClick
callback ->onClick={(id: string) => console.log(id)}
- Override the button's
onClick
with our customonClick
.
Let's choose the latter, because it's more sustainable:
import * as React from "react";
/**
* Utility type
*/
type MergeElementProps< T extends React.ElementType, P extends object = {}> = Omit<React.ComponentPropsWithRef<T>, keyof P> & P;
/**
* Props
*/
type Props = MergeElementProps< "button", { id: string; onClick: (id: string) => void; }>;
/**
* Component
*/
const Button = React.forwardRef(function Button(
{ id, onClick, ...props }: Props,
ref: React.Ref<HTMLButtonElement>
) {
return <button ref={ref} id={id} onClick={() => onClick(id)} {...props} />;
});
function Example() {
return (
<Button id="a" onClick={id => console.log(id)} style={{ margin: 16 }}>
Click me!
</Button>
);
}
Because I use this pattern really often, I've created a utility type called MergeElementProps
. It's a generic type that receives two arguments: the name of the element ('button' on our case), and an object with our custom properties (id
and onClick
on our case). The type then will remove all properties from React.ComponentPropsWithRef
that are also defined in our custom properties. In other words, it merges the element's props with our custom props, and will override the element's props when necessary.
As you can see, TypeScript has only one option when it has to infer the type of the id
argument:

Variants
Let's say we would want a grey button and a color button. The rest of the behavior and styling should be identical among both buttons. What would you do? You could create two separate components, or you could create one (bigger) component where you dedicate a special prop to determine which variant you want to have served. I'm not gonna tell you which choice is better (both have their pros/cons), but I'm going to show you how you could type those 'variant' components.
Since both button variants have two props in common (id
and onClick
), we start by creating a 'base' type:
interface ButtonBaseProps {
id: string;
onClick: (id: string) => void;
}
Notice how we're using a interface
now, instead of a type
. Strangely, this was the only way for me to make this use-case work.
Next we define a type for each variant:
interface ButtonGreyProps extends ButtonBaseProps {
variant: "grey";
}
interface ButtonColorProps extends ButtonBaseProps {
variant: "color";
bgColor: string;
}
Basically this says: the variant can be "grey"
or "color"
, but if you declare "color"
, you'll also need to provide a bgColor
.
We're also extending ButtonBaseProps
in both types, which means that id
and onClick
will be included in the type.
Let's put this together, and apply a bit of styling:
import * as React from "react";
/**
* Utility type
*/
type MergeElementProps<
T extends React.ElementType,
P extends object = {}
> = Omit<React.ComponentPropsWithRef<T>, keyof P> & P;
/**
* Props
*/
interface ButtonBaseProps {
id: string;
onClick: (id: string) => void;
}
interface ButtonGreyProps extends ButtonBaseProps {
variant: "grey";
}
interface ButtonColorProps extends ButtonBaseProps {
variant: "color";
bgColor: string;
}
type ButtonProps = MergeElementProps<
"button",
ButtonGreyProps | ButtonColorProps
>;
/**
* Component
*/
const baseStyle: React.CSSProperties = {
border: 0,
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
textDecoration: "none",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 24,
paddingRight: 24,
height: 40,
borderRadius: 4,
fontWeight: "bold",
};
function ButtonBase(
{ id, onClick, variant, ...rest }: ButtonProps,
ref: React.Ref<HTMLButtonElement>
) {
let backgroundColor = "lightgrey";
let color = "black";
if (variant === "color") {
const props: ButtonColorProps = rest as any;
backgroundColor = props.bgColor;
color = "white";
}
return (
<button
ref={ref}
id={id}
onClick={() => onClick(id)}
{...rest}
style={{
...baseStyle,
backgroundColor,
color,
...rest.style,
}}
/>
);
}
const Button = React.forwardRef(ButtonBase) as typeof ButtonBase;
function Example() {
return (
<Button
id="a"
onClick={id => console.log(id)}
style={{ margin: 16 }}
variant="grey"
>
Click me!
</Button>
);
}
As you can see, we'll get a warning when we defined variant="color"
without defining bgColor
... and that's exactly what we want:

Also notice this:
const Button = React.forwardRef(ButtonBase) as typeof ButtonBase;
Not proud of it, but I had to do this in order for the compiler to understand the two different variants :(
The 'as' prop
Another common use case is to use an element of type A, but make it work as if it were type B. In our case we sometimes want to render a button
element that functions as an anchor
element.
This is a tricky scenario, because it also implies that we need the compiler to check the props differently, depending on the as
prop. So, how are we going to achieve that? Generics to the rescue!
type ButtonProps<P extends React.ElementType = "button"> = {
as?: P;
} & MergeElementProps<P, ButtonGreyProps | ButtonColorProps>;
The ButtonProps
type is now generic, and takes an element-type as an argument which defaults to "button"
. We then provide an optional as
prop, from which TypeScript will infer the actual element-type. This element-type will in turn be passed to the MergeElementProps
type, which merges the inferred element-type's props with our custom ones.
If we put this together:
import * as React from "react";
/**
* Utility type
*/
type MergeElementProps<
T extends React.ElementType,
P extends object = {}
> = Omit<React.ComponentPropsWithRef<T>, keyof P> & P;
/**
* Props
*/
interface ButtonBaseProps {
id: string;
onClick: (id: string) => void;
}
interface ButtonGreyProps extends ButtonBaseProps {
variant: "grey";
}
interface ButtonColorProps extends ButtonBaseProps {
variant: "color";
bgColor: string;
}
type ButtonProps<P extends React.ElementType = "button"> = {
as?: P;
} & MergeElementProps<P, ButtonGreyProps | ButtonColorProps>;
/**
* Component
*/
const baseStyle: React.CSSProperties = {
border: 0,
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
textDecoration: "none",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 24,
paddingRight: 24,
height: 40,
borderRadius: 4,
fontWeight: "bold",
};
function ButtonBase<T extends React.ElementType = "button">(
{ id, onClick, variant, ...rest }: ButtonProps<T>,
ref: React.Ref<HTMLButtonElement>
) {
let backgroundColor = "lightgrey";
let color = "black";
if (variant === "color") {
const props: ButtonColorProps = rest as any;
backgroundColor = props.bgColor;
color = "white";
}
return React.createElement(rest.as || "button", {
ref,
id,
onClick: () => onClick(id),
...rest,
style: {
...baseStyle,
backgroundColor,
color,
...rest.style,
},
});
}
const Button = React.forwardRef(ButtonBase) as typeof ButtonBase;
function Example() {
return (
<Button
id="a"
onClick={id => console.log(id)}
style={{ margin: 16 }}
variant="grey"
as="a"
href="https://www.google.nl"
>
Click me!
</Button>
);
}
When we do as="a"
we should now for example get autocomplete when we type 'hre' (href
). Also the ref
prop gives us the right type:

Test: Extending the Button component
Ok, let's put the examples we've covered to the test!
We want a special kind of button; an <IconButton />
. It is essentially the same button that we've been building, except for the fact that it's a perfect square (width is equal to its height) and has a bigger font-size.
First, we need to export the ButtonProps
so when can reuse it for the <IconButton />
:
export type ButtonProps<P extends React.ElementType = "button"> = {
as?: P;
} & MergeElementProps<P, ButtonGreyProps | ButtonColorProps>;
Next, we need to import it, together with the actual <Button />
component, and enhance it the way as I said earlier:
import * as React from "react";
import Button, { ButtonProps } from "./Button";
function IconButton<P extends React.ElementType = "button">(
{ icon, ...props }: ButtonProps<P> & { icon: string },
ref: any
) {
return (
<Button
ref={ref}
{...(props as ButtonProps<any>)}
style={{
width: 40,
paddingLeft: 0,
paddingRight: 0,
fontSize: 16,
...props.style,
}}
>
{icon}
</Button>
);
}
export default React.forwardRef(IconButton) as typeof IconButton;
Notice how the components signature is practically identical to the <Button />
component. The only thing we're really doing is extending the ButtonProps
with an icon
prop, and adjusting some styles.
Prove that it works:

Conclusion
I showed you some examples of how I make my (primitive) components extensible and I hope you found them useful. Since this article was focussed on the how, you might have some question about why and when your components should be extensible. I recommend to search Google for 'reusability' or 'extensibility'... There are plenty of interesting articles covering the concept more deeply. There are also a lot more patterns that I didn't cover, like 'render-props' or 'HOC's', and maybe I will write a part 2 about them, but for now, this should get you started ;)
Thanks for reading!
Oh, and follow me on Twitter to receive the latest updates 🚀