The Magic of Discriminated Union Types in TypeScript

How to unlock more robust type-checking and clearer code patterns in your TypeScript projects.

ยท

5 min read

This is a handy Typescript trick! In simple terms, I like to describe this feature as conditional types. This feature allows you to do things like the below ApiResponse type, let the code talk for himself

// You can assert the property will have a specific value
// "success" or "error"
type ApiResponse<T> =
  | {
      status: "success";
      data: T;
    }
  | {
      status: "error";
      errorMessage: string;
    };

// Typescript yells at you if failedResponse has status "error" and 
// doesn't have errorMessage
const failedResponse: ApiResponse<User> = {
  status: "error",
  errorMessage: "something went wrong",
};

// Same here, if status is "success", then data must be present
const successfulResponse: ApiResponse<User> = {
  status: "success",
  data: { name: "Michael", email: "michael@jackson.com" },
};

This could be useful for ensuring UI states in components, see below

// Registered user or Admin
type User =
  | {
      name: string;
      email: string;
      createdAt: Date;
      isAdmin: false;
    }
  | {
      name: string;
      email: string;
      createdAt: Date;
      isAdmin: true;
      roles: ["create", "update", "delete"];
    };

export function UserProfile(user: User) {
  return (
    <div>
      <div>Name: {user.name}</div>
      <div>Email: {user.email}</div>
      <div>Created At: {user.createdAt.toISOString()}</div>

      {/* Property roles does not exist on type User */}
      <div>{user.roles}</div>

      {user.isAdmin && (
        <>
          <div>Admin</div>
           {/* The line below does work and user here is the admin with roles */}
          <div>Roles: {user.roles.join(", ")}</div>
        </>
      )}
      user
    </div>
  );
}

The same doesn't work if you try to compose User with two type aliases like below

// Basic User
type RegisteredUser = {
  name: string;
  email: string;
  createdAt: Date;
  isAdmin: false;
};

type AdminUser = RegisteredUser & {
  isAdmin: true;
  roles: ["create", "update", "delete"];
};

// This doesn't work and typescript treat this as a regular union type
type User = RegisteredUser | AdminUser

If the types are defined without extending other types, typescript treats this code as a discriminated union, and we get beautiful IntelliSense!

type RegisteredUser = {
  name: string;
  email: string;
  createdAt: Date;
  isAdmin: false;
};

type AdminUser = {
  name: string;
  email: string;
  createdAt: Date;
  isAdmin: true;
  roles: ["create", "update", "delete"];
};

// This does work and typescript treats them as discriminated types
type User = RegisteredUser | AdminUser;

You may be asking yourself why? The issue stems from how TypeScript processes intersection types and their relationship to union types, especially in the context of discriminated unions.

When we use an intersection type to define AdminUser based on RegisteredUser, it is the same as saying: "An AdminUser is a RegisteredUser with additional properties."

And there's more, the RegisteredUser type states that isAdmin is always false. The AdminUser type then uses an intersection to say it has all the properties of RegisteredUser, and the isAdmin property is true creating a conflict. Since an object can't simultaneously have isAdmin set to both true and false Typescript makes it a regular union type.

This feature is useful when a type has multiple states, some other examples

type UserRole =
  | { role: "guest" }
  | { role: "user"; privileges: string[] }
  | { role: "admin"; privileges: string[]; isSuperAdmin: boolean };

type TaskStatus =
  | { state: "pending" }
  | { state: "running"; progress: number }
  | { state: "completed"; result: any }
  | { state: "failed"; error: string };

type FormState =
  | { state: "idle" }
  | { state: "submitting" }
  | { state: "success"; responseMessage: string }
  | { state: "error"; errorMessage: string };

Following on the same idea of validation, libraries such as Zod, Yup, and Joi can help with schema validation, especially during runtime to validate input from requests and other external services.

Even beyond TypeScript, ensuring your data conforms to certain shapes or schemas is essential. Whether you're receiving payloads from an API, reading configurations, or getting user input, validation is essential. Although TypeScript provides excellent compile-time checks, the dynamic nature of JavaScript and the vast world of data from external sources can bring inconsistencies. This is where runtime validation becomes invaluable.

To illustrate, let's consider the scenario of a Node.js backend API:

Suppose you have an endpoint that accepts user registration details. While TypeScript guarantees type correctness within your code, what happens when a malicious or misbehaving client sends a payload that doesn't match the expected shape?

// user payload
type UserRegistration = {
  username: string;
  email: string;
  password: string;
};

// Actual payload from a client (might not match the TypeScript type)
const payloadFromClient = req.body;

At this point, we rely on runtime validation to check the incoming payload. Zod, Yup, and Joi are among the libraries that allow you to define validation schemas closely resembling your TypeScript types:

// Using Zod for validation
import { z } from 'zod';

const userRegistrationSchema = z.object({
  username: z.string(),
  email: z.string().email(),
  password: z.string().min(8)
});

// Validate the payload
const validationResult = userRegistrationSchema.safeParse(payloadFromClient);

if (!validationResult.success) {
  // Handle validation error, send error response to client
}

By using such a library, you're not just ensuring the shape and type of the data, but you can enforce intricate validation rules like minimum lengths, regex patterns, and more.

Conclusion

Discriminated union types in TypeScript elevate our type definitions, allowing for more precise and conditional typings. While TypeScript does an excellent job at ensuring type safety at compile-time, runtime validation remains indispensable for handling external data. Tools like Zod, Yup, and Joi complement TypeScript by addressing this need, providing a comprehensive approach to data validation.

Harnessing the power of both TypeScript and runtime validation libraries empowers developers to write robust, error-resistant, and self-documenting code, ensuring a smoother and safer development experience.

ย