
Published on:
Discriminated unions in TypeScript: a tool that will level up your TypeScript game
Imagine we're building a geometry application that calculates the area of different shapes. A naive approach (how I used to do it) might look like this:
type Shape = {
type: "circle" | "rectangle";
width?: number;
height?: number;
radius?: number;
}
function getArea(shape: Shape) {
if (shape.type === "circle") {
return Math.PI * shape.radius ** 2
} else if (shape.type === "rectangle") {
return shape.width * shape.height
} else {
throw new Error("Invalid shape!");
}
}
Issues with This Approach
Now, I am sure many of you reading this blogpost are familiar with this approach. You are forced to make all type properties (except for type
) optional, to accomodate the different shape types, because each property may exist on one type of shape but not the other.
If we try typing this code on VSCode for example, its intellisence would not be of great help, because whenever we type in the shape
variable, its autocomplete dropdown will show all of the properties of Shape
type, even the ones we don't need.
Besides, this approach doesn't provide us the type safety that we usually seek by using TypeScript in our projects. So, how can we modify the code above so we can have a better type-safety and nicer help from VSCode via its Intellisence?
Introducing Discriminated Unions
To address these issues, we can use discriminated unions, which provide a structured and type-safe way to model different types.
Refactored Code with Discriminated Unions
type Shape = {
type: "circle";
radius: number;
} | {
type: "rectangle",
width: number;
height: number;
}
function getArea(shape: Shape) {
if (shape.type === "circle") {
return Math.PI * shape.radius ** 2
} else if (shape.type === "rectangle") {
return shape.width * shape.height
} else {
throw new Error("Invalid shape!");
}
}
What did we do different this time?
Well, you'll notice that we've created two different types out of the original Shape
, and both were assigned to the Shape
type via a union type.
Each type has its own properties that are not shared with the other, and they are not optional, but they share one essential property, the type
property, which is a literal string type, and only hold one value this time.
for the circle type for example, it has the type
property with value circle
, and the only other property relevant to it, radius
which is required to calculate the area of the circle.
The type
property identifies each new shape and its own separate properties. Therefore, it is the discriminator
Why Is This Better?
inside the if
statements, if you type shape.type === ""
, VSCode will offer you the two type values as before, circle
and rectangle
, but the real magic is inside the conditional statements blocks.
Try is code at VSCode, and notice intellisence doing its magic by showing only radius
property inside of if (shape.type === "circle") {}
block, and only width
and height
inside if (shape.type === "rectangle") {}
block.
Additional Benefits of Discriminated Unions
1. Better Code Readability
By enforcing explicit type definitions, discriminated unions make it easier to understand what types a function can accept.
2. Extendable Code
It'll be much easier to add more shapes to the Shape
type, for example a triangle, with its own specific properties, without creating an ugly mess of additional optional properties.
3. Preventing Runtime Errors
Since TypeScript can infer the exact type based on the type
property, it prevents common mistakes like accessing properties that don’t exist on a given shape.
4. Leveraging TypeScript's Type Narrowing
TypeScript automatically narrows down types within switch
or if
statements, reducing the need for explicit type assertions.
When to Use Discriminated Unions
Discriminated unions are useful in various scenarios, including:
- State Management: Modeling different states in a UI component (e.g.,
'loading' | 'success' | 'error'
). - API Responses: Handling different response types from an API.
- Event Handling: Processing different event types in a strongly typed way.
- Game Development: Defining different game objects like
Player | Enemy | NPC
.
Conclusion
When I found out about this feature of TypeScript, I was blown away not only about its power, but also its simplicity and how easy it was to use.
I instantly thought about all of my past projects where I could use discriminated unions to refactor the codebase and make it more typesafe. useReducer
action types immediately come to mind. I'll write my next blogpost about it if you hear about useReducer
hook for the first time. Trust me, you'll love it 😉