TypeScript's Discriminated Unions and How I Began to Worry Less

TypeScript's Discriminated Unions and How I Began to Worry Less

A Journey from Type Chaos to Type Safety

If you’ve used Typescript in any real capacity, you’ve probably run into unions. Either defined in third-party libraries or your own definitions. You can define a union with almost any combination or number of types. They’re pretty simple and generally look like this

type Stage = "empty" | "personalInfo" | "billingInfo";

function allowSubmit(stage: Stage) {
  // Do something
}

This ensures that only computationally supported values are passable.

allowSubmit("empty"); // OK
allowSubmit("inPayment"); // Error: Argument of type '"inPayment"' is not assignable to parameter of type 'Stage'.

This by itself made the code more robust. But what about more advanced cases?

Consider the snippet below. It defines a type for attachments of media posts.

type PostAttachment = {
  type: string; // Can be "image", "video" or "audio"
  url: string;
  altText?: string;
  lowResUrl?: string;
  thumbnailUrl?: string;
  autoplay?: boolean;
};

This looks fine on the surface, but that’s a lot more optional properties than I would like. TypeScript will make you explicitly check for the existence of those properties. That's a lot more if statements. Or if you're lazy, a lot of as string assertions (Officer! He's right here!). In short, it's a pain.

This is where discriminated unions come into play. Let’s break down the conditions for the properties.

  1. When the type is image, you will have altText and lowResUrl

  2. When the type is video, you will have altText, thumbnailUrl and autoplay

  3. When the type is audio, you will have only autoplay

Now, let’s transform the above type to something more intuitive.

type Image = {
  type: 'image';
  url: string;
  altText: string;
  lowResUrl: string;
};

type Video = {
  type: 'video';
  url: string;
  altText: string;
  thumbnailUrl: string;
  autoplay: boolean;
};

type Audio = {
  type: 'audio';
  url: string;
  autoplay: boolean;
};

type PostAttachment = Image | Video | Audio;

That looks a lot cleaner doesn’t it? By anchoring to the type property, TypeScript can narrow down on what fields are available.

function processAttachments(attachment: PostAttachment) {
  if (attachment.type === 'image') {
    // TypeScript knows that 'altText' and 'lowResUrl' exist here
    console.log(attachment.altText, attachment.lowResUrl);
  } else if (attachment.type === 'video') {
    // TypeScript knows that 'altText', 'thumbnailUrl', and 'autoplay' exist here
    console.log(attachment.altText, attachment.thumbnailUrl, attachment.autoplay);
  } else {
    // TypeScript knows that 'autoplay' exists here
    console.log(attachment.autoplay);
    console.log(attachment.lowResUrl); // Error: Property 'lowResUrl' does not exist on type 'Audio'.
  }
}

I've kept the example pretty simple to keep the post short. But that should still give you an idea about how powerful these features are. Discriminated unions offer a robust way to handle different and often complex shapes of data, in a type-safe manner. It’s like a Swiss Army knife—versatile, efficient, and indispensable once you understand its uses. They've made my life easier, and I'm sure they'll do the same for you. But remember it’s a Swiss Army knife, not a golden hammer.

Note: Discriminated unions are a TypeScript feature, not an HR issue!