Discriminated Union in TypeScript

Discriminated Union in TypeScript

Polymorphic Components and Advanced Object Handling

Uhm, no discrimination here, at least not in the way you think. You might come across having to define some certain nearly similar objects and you wonder how you can tell React what to expect and what to use. Well, that's where discriminated unions come in.

Prerequisites

You should be familiar with TypeScript. You certainly don't need to be an expert (I certainly don't see myself as one. Not yet tho) but you need to have used it for a while.

What is a Discriminated Union??

Discriminated unions, also known as tagged unions, are used to work with objects that could be of multiple types with a common discriminator property. A good example is defining a User type which can either be an admin or not. They will certainly both have some similar props like name, ID, avatar and role. And that's where it gets interesting. The role would be different, that's the discriminator property. At that point, we can separate a regular user from an admin. The admin can have some properties not available to the regular user, the discriminator will take care of that. Enough of the talk, let's see some code. We could define our object with the role as a union:

type User = {
  ID: string
  name: string
  role: "admin" | "user"
}

But what happens when the admin has some objects that are only available to it?? When can now use a "proper" discriminator.

export type User = {
  id: number
  name: string
  role: "user"
} | {
  id: number
  name: string
  role: "admin"
  adminId: string
}

With this approach, we can now check for the role,which is our discriminator, and render or make use of our user accordingly.

Polymorphic Components

Polymorphic components in React are a pattern that provides component consumers with flexibility to configure the components behaviors via props. When building large react projects, building components to accommodate different variants and elements can be really hard, especially when you're trying to find a way to build reusable and flexible ones. That's where polymorphic components come in. One of the hurdles a developer faces when using polymorphic components in React(TypeScript) is adding the props. Let's consider our input elements, the <input /> , <textarea /> , and <select /> elements are all input elements they have different types. There are a lot of similarities between them but the differences not be ignored by TypeScript. One of the best way I found to type elements is the ComponentProps type from React.

import { ComponentProps } from 'react'

type InputProps = ComponentProps<"input">

This provides all the properties of that element and remove all errors. But how do we type a polymorphic component?? Since we know we can't use same prop-type for different elements, the logical thing would be to use a union.

import { ComponentProps } from 'react'

type InputProps = ComponentProps<"input"> | ComponentProps<"textarea">

const InputElement = (props: InputProps) => {
  if () { //condition to render the select element goes here
    return <select></select>
  }

  return <input />
}

export default InputElement

We have our union now but how do we tell TypeScript which element to use?? We introduce a discriminator.

import { ComponentProps } from "react"

type InputProps =
    | (ComponentProps<"input"> & {
            as: "input"
      })
    | (ComponentProps<"textarea"> & {
            as: "select"
      })

const InputElement = (props: InputProps) => {
    if (props.as === "select") {
        return <select></select>
    }

    return <input />
}

export default InputElement

And here we have it. We check for the as property on the element and render the corresponding element. Although we have what we want, the as property will always be required. We can make it optional for the input so that the input is rendered by default.

import { ComponentProps } from "react"

type InputProps =
    | (ComponentProps<"input"> & {
            as?: "input"
      })
    | (ComponentProps<"textarea"> & {
            as: "select"
      })

const InputElement = (props: InputProps) => {
    if (props.as === "select") {
        return <select></select>
    }

    return <input />
}

export default InputElement

Now, the input is rendered by default if we pass no as property to the component. There are other use cases for discriminated unions. Consider the example below:

type OrderStatus =
    | { status: "pending"; estimatedDelivery: Date }
    | { status: "processing"; itemsProcessed: number }
    | { status: "shipped"; trackingNumber: string; shippedDate: Date }
    | { status: "delivered"; deliveryDate: Date }
    | { status: "cancelled"; cancellationReason: string }

We can get several properties depending on the status property of the object. Another example:

type MessageDelivery =
    | { status: "draft" }
    | { status: "sending"; sentAt: Date }
    | { status: "delivered"; deliveredAt: Date }
    | { status: "failed"; failedReason: string }

type Message = {
  body: string
  sender: string
  recipient: string
} & MessageDelivery

We can even use it for HTTP responses.

type HttpResponse<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

The discriminated union type can be very useful when dealing with objects that can change at any time. In addition to TypeScript's auto-completion, it is very powerful.

Conclusion

We have seen how TypeScript's discriminated unions offer a powerful approach to handling diverse object structures. These unions efficiently categorize object types based on discriminators, adding predictability and flexibility to React components, especially in scenarios with polymorphic components. This approach ensures precise and clear object handling, proving invaluable for complex React applications developed with TypeScript.

if you enjoyed this article, please drop a heart. I am always open to suggestions and feedback; drop a comment or reach me on Twitter.

https://buymeacoffee.com/pablo_clueless