Mastering Discriminated Unions in TypeScript: A Practical Pattern for API Response Handling
Why Discriminated Unions Matter
When I work with external APIs, the shape of the data I receive rarely stays constant. One request might return a user object, while another could expose an error message, a paginated list, or even a warning payload. Trying to keep track of every possible variant using separate types quickly becomes unwieldy. Discriminated unions give us a way to express "this could be one of several distinct shapes" while still preserving type safety. By tagging each variant with a literal property, TypeScript can narrow the type correctly, which means I can write cleaner, more maintainable code without runtime checks.
When API Responses Diverge
Imagine a REST endpoint that returns either a successful payload or an error object. A typical pattern looks like this:
- Success:
{ success: true, data: User } - Failure:
{ success: false, error: string }
In the past I would have defined two separate interfaces and then performed a runtime Below is a small utility I keep in a Notice how the discriminant When I fetch user data, I can now write: If the endpoint ever returns a warning, the runtime check will surface it, and the type guard will still narrow the union. The code stays readable because the intent is explicit: Tip: Keep the discriminant property name consistent across your project. A common choice is Discriminated unions may seem like a purely theoretical construct, but they solve a very concrete problem: modeling data that can take on multiple well‑defined shapes. By tagging each shape with a unique literal, we hand the type checker the responsibility of deciding which branch we are in, which eliminates a class of runtime bugs and makes our APIs easier to reason about. I find that once I start using them, I miss the old ad‑hoc type guards and separate interfaces. Whether you are building a small utility module or a large domain model, adding a discriminant early pays dividends in readability, safety, and long‑term maintainability.if ('data' in response) to decide which branch to take. While that works, it leaves the type system blind to the fact that success
Building a Type‑Safe Helper
src/types/api.ts file. It encapsulates the union definition and provides a type guard that feels natural to read.// src/types/api.ts
/**
* Union representing the shape of any API response.
* The `type` field is the discriminant – every variant must have a unique literal.
*/
export type ApiResponsetypegetPayload'success'payload
Putting It All Together
// inside a component or service
const fetchUser = async (id: string): Promise<User> => {
const raw = await http.get(`/users/${id}`);
if (!isApiResponse(raw)) {
throw new Error('Unexpected API shape');
}
// TypeScript knows raw.type can be 'success', 'error', or 'warning'
return getPayload(raw);
};getPayload
kind or type
Common Pitfalls and Best Practices
any-like behavior. Always enforce the property in each variant.{ type?: 'success' ... } and a separate null case. This keeps the type hierarchy flat.Wrapping Up