Avoid impossible states with Typescript
Often times we are presented with a problem where we need to handle a set of states that are mutually exclusive, each one containing a different set of properties.
For example, let's say we are building a Banner component in React, with two different variants: primary
and secondary
. The primary
variant also expects a subtitle
property, whereas the secondary
variant instead expects a isFixed
boolean property.
Here's a fair first attempt at defining the props for this component:
import * as React from 'react'
type BannerProps = { title: string variant: 'primary' | 'secondary' subtitle?: string isFixed?: string}
export default function Banner({ title, children, variant, subtitle, isFixed,}: React.PropsWithChildren<BannerProps>) { return ( <div className={isFixed ? 'fixed top-0' : null}> <h2 className={variant === 'primary' ? 'text-lg' : 'text-base'}> {title} </h2> {subtitle ? <p>{subtitle}</p> : null} <div>{children}</div> </div> )}
There's nothing specifically wrong about this approach. However, we as developers usually have to write code and components that are meant to be reusable not only by us, but also by other developers. Imagine if this component lives in a UI library we are building from scratch, another developer might pick this one up and use like so:
... <Banner variant="primary" title="Banner title" subtitle="Banner subtitle" isFixed // Yikes, this shouldn't be possible > Some cool banner content here </Banner>...
Granted, this is a contrived example. But you get my point. Let's imagine for a second the other developer didn't read the source code for this component and used it like that. It becomes a problem since isFixed
is not supposed to be used on a primary
variant! The same would go viceversa, subtitle
is only meant to be supported by the primary
variant, yet nothing is stopping us from provinding a subtitle to a secondary
variant 😬
Ok, so let's try another approach then:
export default function Banner({ title, children, variant, subtitle, isFixed,}: React.PropsWithChildren<BannerProps>) { if (variant === 'primary') { return ( <div> <h2 className={variant === 'primary' ? 'text-lg' : 'text-base'}> {title} </h2> {subtitle ? <p>{subtitle}</p> : null} <div>{children}</div> </div> ) }
return ( <div className={isFixed ? 'fixed top-0' : null}> <h2 className={variant === 'primary' ? 'text-lg' : 'text-base'}> {title} </h2> <div>{children}</div> </div> )}
This is... better. Our component is sure enough not going to break now, as there's no subtitle
playing a role on the secondary
variant, and no isFixed
on the primary
variant.
But, things get complicated if we add more complexity to the API on this component. What if we would need to make subtitle
a required property of the primary
variant? There's no way we can do that now with our current API.
Moreover, going back to the other developer, things look pretty much the same. They will be able to provide any set of properties regardles of the variant
they choose. So, why is that?
Discriminated unions
This is happening because of the shape of our props type. We are providing a set of optional properties by default, and so all of them can be declared at any given time. What we need is a way to constrain the component's properties depending on the chosen variant
. In essense, we need Typescript to norrow down the possible current type for us. This is exactly what discriminated unions are useful for. Let's work with them in our example to better understand this:
type PrimaryBanner = { title: string variant: 'primary' subtitle: string}
type SecondaryBanner = { title: string variant: 'secondary' isFixed?: boolean}
type BannerProps = PrimaryBanner | SecondaryBanner
Notice how now we have two distinct types: PrimaryBanner
and SecondaryBanner
? They both share title
and variant
, however their variant
literal is different. When Typescript sees this it considers it to be a discriminated union, and can effectively narrow out members of the union. This means our variant
property is now the discriminator (or "differentiator" if you will) between the two types, which also means we can do something like this:
export default function Banner({ title, children, ...props}: React.PropsWithChildren<BannerProps>) { if (props.variant === 'primary') { return ( <div> <h2 className="text-lg"> {title} </h2> <p>{props.subtitle}</p> <div>{children}</div> </div> ) } else { return ( <div className={props.isFixed ? 'fixed top-0' : ''}> <h2 className="text-base"> {title} </h2> <p>{props.subtitle}</p> {/* ^ ❌ Property 'subtitle' does not exist on type '{ variant: "secondary"; isFixed?: boolean | undefined; }' */} <div>{children}</div> </div> ) }}
This is great! There's also a hidden improvement with this approach, we managed to make subtitle
a required property of the primary
variant 🎉! It goes to say, with discriminated unions you have the freedom of shaping each specific time the way you want. Typescript will just pick the right type and apply it!
Destructuring props
Furthermore, notice how now we are not desctructuring all the props? That's because only title
and variant
are the props shared between the two, but title
is the only one that keeps its shape between the two. If it helps, let's look at our prop types again and do some rearranging:
type PrimaryBanner = { variant: 'primary' subtitle: string}
type SecondaryBanner = { variant: 'secondary' isFixed?: boolean}
type BannerProps = {title: string} & (PrimaryBanner | SecondaryBanner)
Hopefully this makes it more clear! If we were to also destructure variant
from this shape, then no narrowing will be applied! Here's why:
export default function Banner({ title, children, variant, // Huh... we can't destructure `subtitle` and `isFixed` because we don't know if they'll ever exist 🤔 Guess we are back to spreading the rest of the props ...props}: React.PropsWithChildren<BannerProps>) { if (variant === 'primary') { // Huh... now `variant` is one constant and `props` is another one that has no type narrowing. This is no bueno! }}
What's also cool about our approach is that the other developer will now pick up our component and have proper suggestions depending on the chosen variant
:
... <Banner variant="primary" title="Banner title" subtitle="Banner subtitle" isFixed // ❌ Property 'isFixed' does not exist on type 'IntrinsicAttributes & PrimaryBanner & { children?: ReactNode; }' > Some cool banner content here </Banner>...
Notice how also our error message infers that the PrimaryBanner
type is being used. Typescript is so cool!
Conclusion
I hope this article was helpful and you learned something new! I'm sure there are other ways to achieve the same result, but this is the one I found to be the most straightforward and easy to understand. If you have any questions or suggestions, feel free to reach out to me on Twitter @gonstoll, Linkedin or by mail. I'm always happy to chat!
Cheers!