A quick disclaimer: This article assumes intermediate to advanced knowledge of both TypeScript and React. While it will cover some basic refreshers, it would be best for readers to have a moderate level of understanding in using TypeScript with React first.
A refresher on TypeScript
To start with some basics, it’s good to have a refresher as to what TypeScript is and why it’s useful. TypeScript is a programming language that is a superset of the JavaScript programming language, and its use has exploded over the last few years. TypeScript allows us to add static type-checking to our JavaScript code which can have a huge number of benefits, including easier maintenance and debugging and simpler code. For developers migrating to JavaScript from strongly typed languages, TypeScript is usually a sight for sore eyes.
For getting started with TypeScript, there is no better starting point than TypeScript’s website and docs. Check out Brian Smith’s article, Make useContext Data More Discoverable with TypeScript, for more on this topic.
Why use TypeScript with React?
This article won’t list all of the potential benefits of using TypeScript but will talk largely about one of the most obvious and impactful one — component interfaces. Component interfaces allow us to create our own clear and documented API for each component written. This makes code more maintainable for future developers, prevents bugs by checking properties and data-types passed between components, and makes implementation intentions far more obvious.
Here is a simple example showing the difference between a component implemented with and without TypeScript.
/* ItemList.js */
const Item = (props) => {
return (
<li>{`${props.name}: ${props.quantity}`}</li>
)
}
/* ItemList.js */
const ItemList = (props) => {
return (
<ul>
{props.items.map((item) => <Item key={item.name} {...item} />)}
</ul>
)
}
While the components themselves may be simple enough to understand, there are some problems. For one, a future developer will need to dig into the implementation of each component to understand what props can be passed to ItemList. That’s not very good documentation, is already more work for the future developers, and is harder to maintain.
As the components gain complexity, this problem compounds over time and becomes more and more difficult to reason about. People make mistakes and TypeScript is really just a way of making it harder for people to make mistakes.
Here is a new example implementing the same solution with TypeScript:
/* Item.tsx */
export interface ItemProps {
name: string;
quantity: string;}
const Item: React.FC<ItemProps> = (props) => {
return (
<li>{`${props.name}: ${props.quantity}`}</li>
)
}
/* ItemList.tsx */
export interface ItemListProps {
items: Array<ItemProps>}
const ItemList: React.FC<ItemListProps> = (props) => {
return (
<ul>
{props.items.map((item) => <Item key={item.name} {...item}/>)}
</ul>
)
}
Now there is a clearly documented interface informing developers about component usage while also protecting the components from being passed incorrect properties and data (though not at run-time).
For a simple example of this in action, look at what happens when passing a label property to Item without specifying it in the interface.
A detailed error is provided showing the developer that this isn’t what was expected. This added layer of safety allows for the safer spreading of props over components as shown by {...item} in the above example. However, because TypeScript only checks types at compile time, spreading should still be avoided in cases where data in the props object is potentially unpredictable, or where the typed component is being consumed by a JavaScript application rather than a TypeScript one. Without TypeScript or propTypes, or in the listed exceptions, prop spreading is usually considered unsafe because of the possibility of unknown attributes being added to the DOM.
Complex interfaces and correctness
Typically, it’s considered best practice to keep component interfaces simple, small, and reflective of a singular purpose. Developers following that thought process will go far with TypeScript and React. However, like anything, there are exceptions to this rule depending on the use case and the context of implementation.
Exceptions to the simplicity rule often increase as components need to become more general or abstract. Common examples of the need to create increasingly complex components often exist in re-usable component libraries or in components that inherit some kind of styling or theming irrelevant to the actual element used. In these cases, creating correct types can become increasingly difficult and much less than straightforward. This complexity often leads to typings being implemented incorrectly or to developers typing their components as any. In both cases, the benefits of TypeScript are largely canceled out. In the case of any, the developer now has no information on the intention of the data passed to the component. In the case of an incorrect interface, developers are now guided towards incorrect implementations. Incorrect typings and unnecessary use of any are cases where TypeScript actually adds maintenance and complexity to a codebase rather than reducing it.
Here’s a simple example of some requirements of an interface that quickly becomes difficult to implement correctly. Say an app needs to create a simple component that is going to have some themeable styling logic, but the actual element could be anything. This should:
- Allow any HTML element to be specified as the root element.
- Only allow props to be passed that are related to that root HTML element.
- Disallow children if the specified element is a void element.
This is less straightforward than it might seem. The correct typing now depends on a) selecting the correct interface among all HTML elements based on the prop values passed in, and b) filtering React component props based on a subset of these elements.
Let’s look at some solutions that seem valid but aren’t.
const Element: React.FC<any> = ( { as: Component = "div", ...props },
) => {
return <Component {...props} />;
};
This meets requirement 1, but not 2 and 3.
What about an interface that extends a union of element interfaces? We will run into a limitation there as well:
We could try typing the parameter directly with a union type:
type BaseAttributes = React.ComponentPropsWithRef<'div'> | React.ComponentPropsWithRef<'a'>
const Element = ({ as: Component = "div", ...props }: {
as?: React.ElementType
} & BaseAttributes) => {
return <Component { ...props } />;
}
However, we will soon see this still doesn’t pass our requirements 2 or 3, and our component interface surfacing as little better than any.
export default function App() {
return (
<div className="App">
<Element as="div" href="https://www.google.com">{'Hello world!'}</Element>
</div>
);
}
How do we meet requirements 2 and 3?
In order to solve this, we will need to lean on a few relatively abstract TypeScript concepts and to create a few special types.
First, let’s create our prop interface for our Element component. We won’t want an empty interface typically, but we’re assuming in this step that we’ll be adding properties here later.
export interface ElementProps {}
Next, we will create our simple types. Let’s create a type we’ll use to meet requirement number 3. We’ll make a union of element keys representing our void elements (elements not accepting children).
export type VoidElement =
| 'area'
| 'base'
| 'br'
| 'col'
| 'hr'
| 'img'
| 'input'
| 'link'
| 'meta'
| 'param'
| 'command'
| 'keygen'
| 'source'
Now that we’ve created the VoidElement type, let’s create a conditional type omitting the children property in the event a void element is passed. TypeScript allows us to use ternary-like operators to create conditional types.
export type OmitChildrenFromVoid<C extends React.ElementType> =
C extends VoidElement ?
Omit<React.ComponentPropsWithRef<C>, 'children'>
: React.ComponentPropsWithRef<C>
Now if a key belonging to the VoidElement type is passed as a parameter to our new type it will return the element interface without the children prop.
Finally, we’ll move on to our biggest problem. How do we infer our component interface from the value of our as property?
To do this we can first define a function type interface. TypeScript allows us to create interfaces describing the shape and overloads of a function. The React.FC typing is done similar to what we will see here. We will type our React component with this interface once completed.
export interface OverloadedElement {
<C extends React.ElementType>(
props: { as: C }
): JSX.Element;
}
This is a good start but we aren’t quite done. We still need to do two major things. First, we want to handle a default overload for when our as prop doesn’t exist and we want to be able to pass in our own prop interfaces to intersect element attributes.
export interface OverloadedElement<P> {
<C extends React.ElementType>( props: { as: C } & P> ): JSX.Element;
(props: P): JSX.Element;
}
Finally, we want to intersect our custom props with the possible attributes for our selected element and we want to use our OmitChildrenFromVoid type from earlier to meet requirement 3. For correctness, we also want to omit properties from the React.ComponentPropsWithRef<C> type that might also be implemented in our custom prop interface, ElementProps.
export interface OverloadedElement<P> {
<C extends React.ElementType>(
props: { as: C } & P & Omit<OmitChildrenFromVoid<C>, keyof P>
): JSX.Element;
(props: P & Omit<React.ComponentPropsWithRef<'div'>, keyof P>): JSX.Element;
}
const Element: OverloadedElement<ElementProps> = ( { as: Component = "div", ...props },
) => {
return <Component {...props} />;
};
At this point, things should be working the way we want. Though things still aren’t perfect. Why?
The issue here is with our use of Omit. Omit does not behave in the way most would expect over a union type. Omit does not get applied distributively over the subtypes of a union, so it’s potentially unsafe to use Omit if we don’t know whether the types contain a union. In order to do this, we need to create a new type that will apply Omit distributively. Luckily, TypeScript allows us to use conditional types to apply types distributively over a union type.
Let’s take a look at the final code:
export interface ElementProps {}
export type DistributiveOmit<T, K extends keyof any> =
T extends any ? Omit<T, K> : never;
export type VoidElement =
| 'area'
| 'base'
| 'br'
| 'col'
| 'hr'
| 'img'
| 'input'
| 'link'
| 'meta'
| 'param'
| 'command'
| 'keygen'
| 'source'
export type OmitChildrenFromVoid<C extends React.ElementType> =
C extends VoidElement ?
Omit<React.ComponentPropsWithRef<C>, 'children'> :
React.ComponentPropsWithRef<C>
export interface OverloadedElement<P> {
<C extends React.ElementType>(
props: { as: C } & P & DistributiveOmit<OmitChildrenFromVoid<C>, keyof P>
): JSX.Element;
(props: P & DistributiveOmit<React.ComponentPropsWithRef<'div'>, keyof P>): JSX.Element;
}
const Element: OverloadedElement<ElementProps> = (
{ as: Component = "div", ...props },
) => {
return <Component {...props} />;
};
Bonus tip: A simple way to extend this to include unionized interface overloading to your custom props could be to follow a pattern similar to this when creating your prop interfaces.
export interface ElementAlt1Props { b?: 'string'; c?: string }
export interface ElementAlt2Props { b?: 'number'; c?: number; }
export type ElementProps = ElementAlt1Props | ElementAlt2Props;
Results
Here we see mismatching the element type with an invalid attribute will throw an error.
Correcting the element type corrects the error.
Void elements properly complain when children are present.
Void elements stay happy without children.
Success! As you can see, TypeScript is a great tool and can add a lot of benefits to a React project. It’s used in lots of projects including projects here at FullStack Labs. This article was meant to show how much power TypeScript can give your components, but also how the correct implementations can really prevent easy-to-miss bugs and supercharge your development.