little cubes

How to stop using so many optional properties in your React TypeScript props

Zach Olivare --

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.

Evolving props over time

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.

type DocumentUploaderProps = {draftOfferId: string}
function DocumentUploader(props: DocumentUploaderProps) {}

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.

type DocumentUploaderProps = {
draftOfferId?: string
listingId?: string
offerId?: string
}
function DocumentUploader(props: DocumentUploaderProps) {}

Now we can pass either draftOfferId, OR we can pass offerId and listingId. 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 offerId and listingId.

type DocumentUploaderProps = {draftOfferId: string} | {listingId: string; offerId: string}
function DocumentUploader(props: DocumentUploaderProps) {}

Now if we try to pass the wrong combination of props, TypeScript will throw an error:

//
// Type '{ offerId: string; }' is not assignable to type 'DocumentUploaderProps'.
// Property 'listingId' is missing in type '{ offerId: string; }'
// but required in type '{ listingId: string; offerId: string; }'.
<DocumentUploader offerId="789" />

Accessing the props inside the component

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.

type DocumentUploaderProps = {draftOfferId: string} | {listingId: string; offerId: string}
function DocumentUploader(props: DocumentUploaderProps) {
// Property 'draftOfferId' does not exist on type 'DocumentUploaderProps'.
// Property 'listingId' does not exist on type 'DocumentUploaderProps'.
// Property 'offerId' does not exist on type 'DocumentUploaderProps'.
const {draftOfferId, listingId, offerId} = props
}

In order to safely access the props, we can use the in keyword to check if a property exists on the props object.

function DocumentUploader(props: DocumentUploaderProps) {
if ('draftOfferId' in props) {
// props is {draftOfferId: string}
console.log(props.draftOfferId)
} else {
// props is {listingId: string; offerId: string}
console.log(props.listingId)
console.log(props.offerId)
}
}

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.

function DocumentUploader(props: DocumentUploaderProps) {
const offerId = 'offerId' in args ? args.offerId : args.draftOfferId
// ^? const offerId: string
const listingId = 'listingId' in args ? args.listingId : undefined
// ^? const listingId: number | undefined
}

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.

Discriminated Union

Now this is where things get fun!

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.

type OfferSpecifier =
| {type: 'draft'; draftOfferId: string}
| {type: 'submitted'; listingId: string; offerId: string}

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.

function myFunc(offerSpecifier: OfferSpecifier) {
if ('draftOfferId' in offerSpecifier) {
if (offerSpecifier.type === 'draft') {
// offerSpecifier is {type: 'draft'; draftOfferId: string}
console.log(offerSpecifier.draftOfferId)
} else {
// offerSpecifier is {type: 'submitted'; listingId: string; offerId: string}
console.log(offerSpecifier.listingId)
console.log(offerSpecifier.offerId)
}
}

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.

function myFunc(offerSpecifier: OfferSpecifier) {
if ('asdf' in offerSpecifier) {
console.log(offerSpecifier.draftOfferId) // ERROR!!
// Property 'draftOfferId' does not exist on type 'OfferSpecifier & Record<"asdf", unknown>'.
}
}

Exhaustiveness checking

So you’ve got this discriminated union and you want to go through each of the possible cases.

function myFunc(offerSpecifier: OfferSpecifier) {
switch (offerSpecifier.type) {
case 'draft':
console.log(offerSpecifier.draftOfferId)
break
case 'submitted':
console.log(offerSpecifier.listingId)
console.log(offerSpecifier.offerId)
break
}
}

And that’s great, until someone goes and adds another possible type:

type OfferSpecifier =
| {type: 'draft'; draftOfferId: string}
| {type: 'submitted'; listingId: string; offerId: string}
| {type: 'archived'; foobar: string} // <-- this is new

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.

function myFunc(offerSpecifier: OfferSpecifier) {
switch (offerSpecifier.type) {
7 collapsed lines
case 'draft':
console.log(offerSpecifier.draftOfferId)
break
case 'submitted':
console.log(offerSpecifier.listingId)
console.log(offerSpecifier.offerId)
break
default:
// The next line will throw an error because
// we failed to handle the new 'archived' case
const _exhaustiveCheck: never = offerSpecifier // ERROR!
// Type '{ type: "archived"; foobar: string; }'
// is not assignable to type 'never'.
}
}

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:

/**
* This function is useful to enforce that you have handled all
* possible cases in a final (default) `switch` or `if-elseif-else`
* check. After all possible cases have been handled, it should be
* of type `never`, in which case passing it to this function will
* not throw an error.
*
* This function throws a TypeScript error at compile time if all
* cases have not been handled and a JS error at runtime if the
* unhandled case is triggered.
*/
function assertNever(x: never): never {
throw new Error('Unexpected value. Should have been never: ' + x)
}
function myFunc(offerSpecifier: OfferSpecifier) {
switch (offerSpecifier.type) {
7 collapsed lines
case 'draft':
console.log(offerSpecifier.draftOfferId)
break
case 'submitted':
console.log(offerSpecifier.listingId)
console.log(offerSpecifier.offerId)
break
default:
return assertNever(offerSpecifier)
}
}

Bonus Tip: Passing Union Props Between Components

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.