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:
typescriptlet 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:
typescriptlet 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:
typescriptfunction 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:
typescriptfunction 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:
typescripttype Circle = { type: "circle"; radius: number; }; type Square = { type: "square"; size: number; };
Let's create a function that calculates a square for each shape:
typescriptfunction 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:
typescriptfunction 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:
typescripttype 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:
typescriptfunction 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:
typescriptconst 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!