Understanding TypeScript Discriminated Unions
Coming from a C# background, moving to TypeScript was a bit of a culture shock, but I am loving it. In this blog post I talk about a TypeScript feature which I found useful in my current work, discriminated union, also known as a tagged union or algebraic data type. This powerful feature enables more precise type-checking (wink-wink C#) and can significantly enhance the safety and clarity of your code. We recently used discriminated unions for components we built for a React Native app.
Let’s get an understanding of what discriminated unions are first.
What are Discriminated Unions?
Discriminated unions are a TypeScript feature that allows you to combine multiple types into a single type. Each type in the union has a common property (the discriminant) with literal types that TypeScript can use to differentiate between the types.
Why Use Discriminated Unions?
- Improved Type Safety: They ensure that certain code paths are only accessible with the correct type.
- Better Code Organization: They can represent complex data structures more clearly.
- Easier Maintenance: They help in catching errors during compile time, reducing runtime errors.
Basic Discriminated Union
To get a better understanding of the concept, let’s start with a simple example. Suppose we have an application that deals with different shapes, and we need to calculate the area for each shape.
type Circle = {
kind: 'circle';
radius: number;
};
type Square = {
kind: 'square';
sideLength: number;
};
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
}
}
Here, the kind
property is the discriminant. It tells TypeScript which type of shape it's dealing with, allowing the getArea
function to correctly calculate the area based on the shape type.
Handling More Complex Structures
Let’s look at a more complex scenario where we handle user actions in an application.
type LoadAction = {
type: 'load';
payload: { url: string };
};
type SaveAction = {
type: 'save';
payload: { data: string };
};
type Action = LoadAction | SaveAction;
function handleAction(action: Action) {
switch (action.type) {
case 'load':
console.log(`Loading from ${action.payload.url}`);
break;
case 'save':
console.log(`Saving data: ${action.payload.data}`);
break;
}
}
Here, we see that the type
property is the discriminant. It allows the handleAction
function to understand what action to perform based on the action type.
Best Practices
- Use Literal Types for Discriminant: Always use string literal types or numeric literal types for the discriminant property.
- Cover All Cases in Switch: Make sure to handle all possible cases in a switch statement when using discriminated unions. TypeScript’s exhaustive check will help with this.
- Keep It Simple: Don’t overuse discriminated unions. They are powerful, but not every scenario requires them.
Conclusion
TypeScript’s discriminated unions are a robust feature for handling complex data structures and logic flows in a type-safe manner. By understanding and utilizing this feature, developers can write more predictable and maintainable code. Remember, the power of TypeScript lies in its type system, and discriminated unions are a key part of making the most of this system.