Using TypeScript unions to define relationships between props
A problem that I see quite frequently with React components is that as they grow over time, they need to accept one set of props, OR another set of props.
Lets look at a real world example that involves uploading documents to an offer on a real estate listing.
Our component DocumentUploader needs to accept enough information to identify the offer where the document should be uploaded to. When the offer is still in a draft state, just the id is enough to identify the offer.
But once the offer is submitted, just the offer id is no longer enough to properly identify the offer; we also need the id of the listing that the offer has been submitted to. Lets add those two new props to the component.
Now we can pass either draftOfferId, OR we can pass offerIdandlistingId. We could document that relationship in the documentation for this component. But wouldn’t it be nice if TypeScript could enforce that relationship for us so that we don’t have to rely on the next developer to (gasp) read the documentation?
We can use TypeScript’s union (OR) types to enforce that relationship. We can define a type that accepts either draftOfferId OR offerIdandlistingId.
Now if we try to pass the wrong combination of props, TypeScript will throw an error:
One difficulty with defining our props as a union type is that we can no longer access the props directly because TypeScript can’t be certain which props exist and which don’t.
In order to safely access the props, we can use the in keyword to check if a property exists on the props object.
But sometimes that if structure is awkward in React components. If you would instead prefer to destructure all your props at the top of the component, you can structure the in checks differently.
The advantage of the if structure is that inside of the if you can be certain for example that listingId is a string, but in the ternary structure listingId when used later might be undefined and you’ll have to handle that case. That might be fine or it might be annoying, depending on your use case.
A discriminated union is just a union type where all variants share a common property (often called type but can have any name) that can be used to distinguish between the different variants of the union.
So now instead of using the in keyword to check if a property exists on the object, we can use the type property (which always exists) to check which variant of the union we were given.
One advantage of a discriminated union is that you get autocomplete when comparing against the type property. TypeScript knows that the type property can only be one of the two strings (in this example) and will autocomplete those strings for you.
With the in method that we used for our non-discriminated/regular union, you don’t get autocomplete for the property name. The in method is still strongly typed after a fashion though because if you mess it up you will get a type error (because the type doesn’t get properly narrowed), just not directly on that line.
So you’ve got this discriminated union and you want to go through each of the possible cases.
And that’s great, until someone goes and adds another possible type:
Now that switch statement that you wrote needs to be updated to handle the new case, but unless you remembered that it exists or you do some kind of project-wide search for “OfferSpecifier” or something, it’s really easy to forget to change that switch at all and not discover this bug until it’s reported by a user.
But fear not! Another advantage of using a discriminated union is that we can make TypeScript enforce that we handle all the cases in our union, and throw an error if we don’t (or if more cases are added later). We do that by asserting that the type of the union is never inside of the default case of the switch statement.
The only way that offerSpecifier can be of type never at the end of the switch statement is if every possible valid case has been handled. If a new case is added to the union, then the default case will throw an error because it is no longer exhaustive.
You can codify this never functionality with an assertNever helper function:
I’ve found that the union type is often a group of props that I have to “prop drill”, or pass around to several components or hooks. To avoid having to destructure the props with the in keyword in parent components that don’t even make direct use of them, instead of mixing the union with the rest of the props, it can be helpful to pass around the union in its own object.
One way to isolate the union in its own object is using a rest parameter. You can then spread that rest parameter onto the child components.
But this strategy breaks down if want to follow the common practice of also accepting arbitrary HTML props that you can spread onto your root element.
So to be able to easily pull out the union props without having to know which set of props were passed (at the parent level component at least), what if instead of including the union on the root props object, we give it its own key inside of props, and then pass the union as an object?
Now the union is neatly gathered into its own object that the parent component doesn’t need to know anything about. The parent component can just pass the union as an object, and the child component can destructure the union props from that object as needed. And the ...rest parameter is reserved for all the myriad HTML props that you might want to pass to your root element.