Discriminated Unions in TypeScript

May 15th, 2024

·

3 min read

What is a Discriminated Union?

Discriminated Unions are a handy feature available to us in TypeScript, they are also known as Tagged Unions.

A Discriminated Union type is like many other types, but they include a discriminator property. This discriminator property is most commonly a string literal type that is unique to a particular instance of a type. Because the discriminator is unique it allows TypeScript to infer precisely what type a variable might be where it might have otherwise been ambiguous.

A Contrived Example

We have a function that can calculate the area of a shape;

const calculateShapeArea = (shape: unknown): number => {
// ... How do we know what type of shape we are dealing with?
};

But they way we calculate the area of a circle is not the same as the way we calculate the area of a square. Let's create a few types to represent our shapes;

type Circle = {
radius: number;
};

type Rectangle = {
width: number;
height: number;
};

type Square = {
size: number;
};

type Shape = Circle | Rectangle | Square;

We can now update our function to use the Shape type. We still can't determine what type of shape we are dealing with directly, but with a few checks we can assert the type of shape.

const calculateShapeArea = (shape: Shape): number => {
if (shape.radius) {
return Math.PI * shape.radius ** 2;
} else if (shape.width && shape.height) {
return shape.width * shape.height;
} else if (shape.size) {
return shape.size ** 2;
}
};

This works but it's a little cumbersome. We can improve this by using a Discriminated Union. We will start by adding a type property to each of our shapes.

type Circle = {
type: "circle";
radius: number;
};

type Rectangle = {
type: "rectangle";
width: number;
height: number;
};

type Square = {
type: "square";
size: number;
};

type Shape = Circle | Rectangle | Square;

Now we can use the type propertly to determine what type of shape we are dealing with.

const calculateShapeArea = (shape: Shape): number => {
switch (shape.type) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "square":
return shape.size ** 2;
}
};

While functionally very simmilar to the previous implementation, it's much easier to see what kinds of shapes our function can handle. A nice added benefit is that our editor can now infer the type of shape we are dealing with and provide us with helpful auto-complete suggestion.

Using Zod

If you are using Zod to validate your data, you can use Discriminated Unions to make your code even more robust.

import { z } from "zod";

const CircleSchema = z.object({
type: z.literal("circle"),
radius: z.number(),
});

const RectangleSchema = z.object({
type: z.literal("rectangle"),
width: z.number(),
height: z.number(),
});

const SquareSchema = z.object({
type: z.literal("square"),
size: z.number(),
});

const ShapeSchema = z.union([Circle, Rectangle, Square]);
type Shape = z.infer<typeof ShapeSchema>;