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.
What is a Discriminated Union?
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
The Problem with Regular Union Types
Consider our first example using regular union types:
type ErrorResponse = {
message: string;
statusCode: number;
cause: string;
};
type SuccessResponse = {
statusCode: number;
data: Record<string, unknown>;
};
// Union type to represent the possible responses
type Result = ErrorResponse | SuccessResponse;
When working with this type, we need to use property checking to determine which type we're dealing with:
const fetchedData = await fetchData("https://example.com/api");
// Check for a property that only exists in one variant
if ("message" in fetchedData) {
// TypeScript narrows fetchedData to ErrorResponse
return { error: fetchedData.message };
}
// TypeScript narrows fetchedData to SuccessResponse
const 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
The Solution: Discriminated Unions
A better approach is to use a discriminated union with an explicit tag:
type ErrorResponse = {
message: string;
statusCode: number;
cause: string;
response_type: "error"; // The discriminant
};
type SuccessResponse = {
message: string;
statusCode: number;
data: Record<string, unknown>;
response_type: "success"; // The discriminant
};
type Result = ErrorResponse | SuccessResponse;
With this pattern, type narrowing becomes explicit and reliable:
const fetchedData = await fetchData2("https://example.com/api");
if (fetchedData.response_type === "error") {
// TypeScript knows fetchedData is ErrorResponse
return { error: fetchedData.message };
}
// TypeScript knows fetchedData is SuccessResponse
const response = fetchedData.data;
Benefits of Discriminated Unions
- 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.
Real-World Example: API Response Handling
In our example application, we've implemented API response handling in two ways:
Without Discriminated Unions:
app.get("/create", async (c) => {
const fetechedData = await fetchData("https://jsonplaceholder.typicode.com/posts");
// implicit ErrorResponse because of the error key
if ("message" in fetechedData) {
return c.json({ error: fetechedData.message }, 500);
}
// The type here is SuccessResponse & Record<"error", unknown>
if ("error" in fetechedData) {
return c.json({ error: fetechedData.error }, 500);
}
// implicit SuccessResponse because of the data key
if ("data" in fetechedData) {
return c.json({ message: fetechedData.data }, 500);
}
// We have to handle every possible case to avoid type errors
const response = fetechedData.data;
return c.json(response, 200);
});
With Discriminated Unions:
app.get("/create", async (c) => {
const fetechedData = await fetchData2("https://jsonplaceholder.typicode.com/posts");
// Using our discriminator we can easily narrow the type
if (fetechedData.response_type === "error") {
return c.json({ error: fetechedData.message }, 500);
}
// TypeScript knows fetechedData must be SuccessResponse2 here
const response = fetechedData.data;
return c.json(response, 200);
});
Best Practices
-
Use a Consistent Property Name: Common conventions are
type
,kind
, ortag
. - 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.
Conclusion
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.