How union let me sleep tight

Discriminated Union is my one of the most favorite mechanisms delivered by TypeScript. Maybe you are not aware, but unions are perfect for modeling business domain inside your application. In this post, I will show you, how to make your code typed stronger and more business oriented.

Simple model, what can go wrong?

Let’s imagine the situation when we have different set of data depending on the situation. In school system we can ask to postpone our exam. If our submission is approved, we should see Approved status with new date of exam. In the case of rejection, what unfortunately sometimes happens, we would like to know why we were rejected.

So, let’s summarize everything in code:

enum SubmissionStatus {
  Approved = "Approved",
  Rejected = "Rejected",
  Pending = "Pending",
}

type Submission = {
  status: SubmissionStatus;
  reason?: string;
  examDate?: Date;
};

In our Submission, we consider vary of data as a result. If we succeeded, then we get examDate, on the other hand, we should expect reason string. If submission is pending, then we have no extra data.

The problem is, that you, as a developer, have to remember about all of those things…

No entrance for enums

It’s time to clean up. Let’s set some rule at the beginning: we don’t want to use enums. Why? Enum as a data structure is not type-only. Creating enum means we transfer our types into JavaScript code for no reason. Instead of enum we can use literal type. The main difference is that literal type won’t be transpiled to JavaScript, but still will work as expected.

⚠️ Sidenote: I use enums only there, where I want to iterate through values. In other cases, literals are sufficient.

So, the first change would look like this:

type Submission = {
  status: "Approved" | "Rejected" | "Pending";
  reason?: string;
  examDate?: Date;
};

const popUpNewDate = (submission: Submission) => {
  if (submission.status === "Approved" && submission.examDate) {
    alert(submission.examDate.toString());
  }
};

Discriminate use cases

Back to the merits: the code is simpler, but our new function popUpNewDate still has to know so much about our business logic! The good thing is that we can use TypeScript to simplify it. And here the discriminated unions come to the stage. 

Basically, discriminated unions are just variants of different cases in boundaries of one type. In the previous example, we can see union types of statuses - it can be set as “Approved” OR “Pending” OR “Rejected”. All three are valid, but TypeScript allows us to extend those unions to make them more verbose. To do this, we need some “discriminant” - property which will be unique in scope of this type and which identify the variant. In our case, it’s status and for each status we have a different set of data.

But take a look first at this example:

type Animal =
  | {
      type: "dog";
      bark: () => void;
    }
  | {
      type: "cat";
      goToRoof: () => void;
    }
  | {
      type: "sloth";
      sleep: () => void;
    };

By using discriminated union, we combined three types of animals with different abilities. For each one, we have a specific method. If we make sure that animal.type === "dog", then TSC won’t let us call any other method than animal.bark(). That’s the moment when it takes the responsibility of business logic for us.

Now it’s time to reshape our case by discriminated union: 

type Submission =
  | {
      status: "Approved";
      examDate: Date;
    }
  | {
      status: "Pending";
    }
  | {
      status: "Rejected";
      examDate: Date;
      reason: string;
    };

const popUpNewDate = (submission: Submission) => {
  submission.status === "Approved" && submission.examDate.toString();

  if (submission.status === "Pending") {
    alert(submission.reason); // Error: Property 'reason' does not exist on type '{ status: "Pending"; }'
  }
};

Submission has now three variants, depending on status. And status is our discriminator. If we have Approved status, we are 100% sure that it has examDate field. The best part is that we don’t have to check it programmatically by ifs, TypeScript compiler does this for us.

In such case we transfer business logic to compiler. In addition, when you read such code you exactly know, what set of data you will get. You don’t have to ask your colleagues, why we expect examDate for pending status.

Discriminated unions simplify life, code and docs. TypeScript delivers us a great tool to describing business rules.

I hope you found it useful and your code will be typed stronger.