paint-brush
The Power of Discriminated Unions in TypeScriptby@tokenvolt

The Power of Discriminated Unions in TypeScript

by Oleksandr KhrustalovOctober 5th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

This story overviews the basics of discriminated unions in TypeScrips. I use them quite often in my development, so we will look at a certain example on how to apply them.
featured image - The Power of Discriminated Unions in TypeScript
Oleksandr Khrustalov HackerNoon profile picture

Stating the problem

As typescript is growing and gaining popularity recently, more and more javascript developers appreciate type safety. The list of features Typescript provides is huge and might be overwhelming, so in this post, I will focus on one of them which is easy to grasp and has a neat practical impact.


Let's start with an example. Imagine you are developing an application with many user roles. It is pretty common for an application to be consumed by different users, isn't it? The exact roles are not really important here, but let's say they are admin, consumer and guest. In typescript, we can declare users holding those roles as follows:


type Admin = {}
type Consumer = {}
type Guest = {}


Now, let's consider a set of attributes each user role has. Usually, they are email, firstName and lastName or something like that. But, wait, Guest users probably won't have those (they are guests after all), so let's just leave this type empty for now.


type Admin = {
  firstName: string
  lastName: string
  email: string
}

type Consumer = {
  firstName: string
  lastName: string
  email: string
}

type Guest = {}


The user of an application could only be of one role. The way to represent this through types is to use a union type.


type User = Admin | Consumer | Guest


Admins are famous for their exclusive abilities, and in our application, they are able to invite consumers. Let's add a field indicating how many invitations an admin could send.


type Admin = {
  firstName: string
  lastName: string
  email: string
  numberOfInvitesLeft: number // <-- added
}


To make things more interesting and closer to a real application, let's add a property exclusive to a Consumer type.


type Consumer = {
  firstName: string
  lastName: string
  email: string
  premium: boolean // <-- added
}


This is a very simple example, and in reality, users could have dozens of disparate properties, which considerably complicates the codebase when you need to access certain properties.


const doSomethingBasedOnRole = (user: User) => {
  // how do you check here that user is really an admin
  if (user) {
    // ...and do something with the `numberOfInvitesLeft` property?
  }
}


One option is to check on the existence of the property.


const doSomethingBasedOnRole = (user: User) => {
  if (user && user.numberOfInvitesLeft) {
    // safely access `numberOfInvitesLeft` property
  }
}


But this is a tedious and not a scalable solution. And what to do when `numberOfInvitesLeft` becomes an optional property?

Introducing Discriminated Union Types

This is where discriminated union types come into play. We just need to put an additional field in every user type indicating the role.


type Admin = {
  firstName: string
  lastName: string
  email: string
  numberOfInvitesLeft: number
  role: "admin" // <-- added
}

type Consumer = {
  firstName: string
  lastName: string
  email: string
  role: "consumer" // <-- added
}

type Guest = {
  role: "guest" // <-- added
}


Notice how I am putting a specific string as a type; this is called string literal type. What this gives you is that now you can use native JS language operators, e.g., switch case, if, else to discriminate on the role.


const user: Admin = {
  firstName: "John",
  lastName: "Smith",
  email: "[email protected]",
  numberOfInvitesLeft: 3,
  role: "admin",
}

const doSomethingBasedOnRole = (user: User) => {
  if (user.role === "admin") {
    // now typescript knows that INSIDE of this block user is of type `Admin`
    // now you can safely call `user.numberOfInvitesLeft` within this block
  }
}


The same applies to a switch case statement.


// ...

const doSomethingBasedOnRole = (user: User) => {
  switch (user.role) {
    case "admin": {
      // now typescript knows that INSIDE of this block user is of type `Admin`
      // now you can safely call `user.numberOfInvitesLeft` within this block
    }
    case "consumer": {
      // do something with a `Consumer` user
      // if you try to call `user.numberOfInvitesLeft` here, TS compiler errors in
      //
      // "Property 'numberOfInvitesLeft' does not exist on type 'Consumer'."
      //
    }
  }
}


The benefits of discriminated union types are apparent because the type checking is based on explicit role property and not on ad-hoc properties which might or might not be related to a specific user.