blog post

Mastering Discriminated Unions in TypeScript

What Are Discriminated Unions

Often, applications require variables and functions that have multiple types of input or output. TypeScript has a special Union Type for such use cases, called Discriminated Unions.

Discriminated Unions in TypeScript are formed from two or more other types. A variable of discriminated union can be one of the types from the union.

Here's a simple example where a variable can hold either a number or a string:

typescript
let id: number | string; id = 100; // This code is correct id = "200"; // This code is correct // TypeScript Error: Type 'boolean' is not assignable to type 'string | number'. id = false;

Unions are also useful when combining string literals. A literal in TypeScript is a special type for constant string, for example:

typescript
let text: "TypeScript" = "TypeScript"; text = "TypeScript"; // This code compiles // TypeScript Error: Type '"JavaScript"' is not assignable to type '"TypeScript"'. text = "JavaScript";

By combining literals into unions you can express that a variable can only accept a certain set of values:

typescript
function print(value: string, align: "left" | "right" | "center") { // ... } print("TypeScript types", "left"); // TypeScript Error: "align" parameter should be one of: "left" | "right" | "center" print("TypeScript types", "middle");

When using discriminated unions, TypeScript will only allow to access properties or methods that are common to all types in the union. To use type-specific properties or methods, you need to use type guards for built-in types:

typescript
function getLength(obj: string | string[]) { if (typeof obj === "string") { return obj.length; // Return length of a string } return obj.length; // Return legnth of string array }

Discriminated Unions With Object Types

Discriminated Unions can combine object types too. Let's explore an example of geometrics shapes, that have common and different properties:

typescript
type Circle = { type: "circle"; radius: number; }; type Square = { type: "square"; size: number; };

Let's create a function that calculates a square for each shape:

typescript
function getSquare(shape: Circle | Square) { if (shape.type === "circle") { return Math.PI * shape.radius * shape.radius; } if (shape.type === "square") { return shape.size * shape.size; } } const circle: Circle = { type: "circle", radius: 10 }; const square: Square = { type: "square", size: 5 }; console.log("Circle square: ", getSquare(circle)); console.log("Circle square: ", getSquare(square));

Here a getSquare function accepts a shape parameter that can be one of 2 types: Circle or Square. When working with custom types you can use a property that will allow to distinguish types from each other. In our example it's a type property. Type guards here are straightforward:

typescript
function getSquare(shape: Circle | Square) { if (shape.type === "circle") { return Math.PI * shape.radius * shape.radius; } if (shape.type === "square") { return shape.square; } return 0; }

TypeScript is smart enough to know that we are working with Circle or Square type.

Using Discriminated Unions as Function Return Type

Another example where discriminated unions are useful - is a function return type. Let's explore an example of placing an order for a product by its ID and desired quantity. The return type of this function will be a discriminated union representing three possible states:

  • order successfully created
  • product not found
  • not enough product quantity in the warehouse

The naive approach is to declare a big object with properties that cover all three cases. But such implementation is cumbersome.

Discriminated unions offer a more elegant approach:

typescript
type OrderResponse = OrderCreated | ProductNotFound | NoProductQuantity; type OrderCreated = { type: "order-created"; orderId: string; message: string; }; type ProductNotFound = { type: "product-not-found"; message: string; }; type NoProductQuantity = { type: "no-product-quantity"; message: string; };

Now we can use this union type as a function's return type. We are getting products from a database or an external API, but for simplicity we will mock the data:

typescript
function orderProduct(productId: string, quantity: number): OrderResponse { const productsDatabase = { "Mobile Phone": { stock: 10 }, "Smart TV": { stock: 0 }, }; const product = productsDatabase[productId]; if (!product) { return { type: "product-not-found", message: "Product not found" }; } if (quantity > product.stock) { return { type: "no-product-quantity", message: "Insufficient product quantity" }; } return { type: "order-created", orderId: `order-${new Date().getTime()}`, message: "Order has been successfully created" }; }

In this function we are returning 3 types of responses: "order-created", "product-not-found", "no-product-quantity". To order a product let's call the method:

typescript
const orderResult = orderProduct("Mobile Phone", 1); switch (orderResult.type) { case "order-created": console.log(`Success: ${orderResult.message} (Order ID: ${orderResult.orderId})`); break; case "product-not-found": console.log(`Error: ${orderResult.message}`); break; case "no-product-quantity": console.log(`Error: ${orderResult.message}`); break; }

As you can see discriminated unions make code more readable and maintainable in such use cases.

Summary

Discriminated unions in TypeScript offer a robust way to manage different data types under a united type. They enhance type safety, improve code clarity, and ensure that all possible cases are handled, reducing runtime errors. Using discriminated unions as function input and output types will significantly improve the quality and maintainability of your TypeScript applications.

Hope you find this blog post useful. Happy coding!

Improve Your .NET and Architecture Skills

Join my community of 2300+ developers and architects.

Each week you will get 1 practical tip with best practises and architecture advice.