Discriminated unions (also known as tagged unions) are a powerful TypeScript pattern that enables type-safe handling of values that could be of different types. They're especially useful when dealing with API responses, state management, or any scenario where you need to handle different outcomes.
A discriminated union is a data structure where:
- Each possible type in the union contains a common property (the "discriminant")
- This property has a different literal type for each variant
- TypeScript uses this property to narrow down the type when checking conditions
Consider our first example using regular union types:
1type ErrorResponse = {
2 message: string;
3 statusCode: number;
4 cause: string;
5};
6
7type SuccessResponse = {
8 statusCode: number;
9 data: Record<string, unknown>;
10};
11
12
13type Result = ErrorResponse | SuccessResponse;
When working with this type, we need to use property checking to determine which type we're dealing with:
1const fetchedData = await fetchData("https://example.com/api");
2
3
4if ("message" in fetchedData) {
5
6 return { error: fetchedData.message };
7}
8
9
10const response = fetchedData.data;
This approach has several issues:
- It relies on the presence of specific properties
- You need to know which properties are unique to each type
- There's no guarantee that you've exhaustively handled all possibilities
- Refactoring can easily break the type narrowing
A better approach is to use a discriminated union with an explicit tag:
1type ErrorResponse = {
2 message: string;
3 statusCode: number;
4 cause: string;
5 response_type: "error";
6};
7
8type SuccessResponse = {
9 message: string;
10 statusCode: number;
11 data: Record<string, unknown>;
12 response_type: "success";
13};
14
15type Result = ErrorResponse | SuccessResponse;
With this pattern, type narrowing becomes explicit and reliable:
1const fetchedData = await fetchData2("https://example.com/api");
2
3if (fetchedData.response_type === "error") {
4
5 return { error: fetchedData.message };
6}
7
8
9const response = fetchedData.data;
- Type Safety: TypeScript can verify that you've handled all possible cases.
- Explicit Intent: The code clearly communicates the different possibilities.
- Refactoring Resilience: Adding new variants forces you to update all the code that handles the union.
- Self-Documenting: The discriminant property makes the code more readable.
- IDE Support: Better autocomplete and error checking.
In our example application, we've implemented API response handling in two ways:
1app.get("/create", async (c) => {
2 const fetechedData = await fetchData("https://jsonplaceholder.typicode.com/posts");
3
4
5 if ("message" in fetechedData) {
6 return c.json({ error: fetechedData.message }, 500);
7 }
8
9
10 if ("error" in fetechedData) {
11 return c.json({ error: fetechedData.error }, 500);
12 }
13
14
15 if ("data" in fetechedData) {
16 return c.json({ message: fetechedData.data }, 500);
17 }
18
19
20 const response = fetechedData.data;
21 return c.json(response, 200);
22});
1app.get("/create", async (c) => {
2 const fetechedData = await fetchData2("https://jsonplaceholder.typicode.com/posts");
3
4
5 if (fetechedData.response_type === "error") {
6 return c.json({ error: fetechedData.message }, 500);
7 }
8
9
10 const response = fetechedData.data;
11 return c.json(response, 200);
12});
- Use a Consistent Property Name: Common conventions are
type, kind, or tag. - Use String Literals: They're more maintainable than numbers or booleans.
- Make the Discriminant Required: Don't make it optional.
- Keep the Union Simple: Don't overload with too many variants.
- Use Exhaustiveness Checking: Ensure all variants are handled.
Discriminated unions provide a robust pattern for handling multiple related types in TypeScript. By adding a specific property with a unique value for each type variant, you gain compile-time safety and improved code clarity. This pattern is particularly valuable when dealing with operations that can produce different outcomes, like API responses or state transitions.