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 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

Below is a small utility I keep in a 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 ApiResponse =
  | { type: 'success'; payload: T }
  | { type: 'error'; message: string }
  | { type: 'warning'; code: number; details?: string };

/**
 * Type guard that narrows an unknown value to an ApiResponse.
 * While the compiler can infer this, an explicit guard improves readability
 * and gives us a place to add runtime validation if needed.
 */
export function isApiResponse(value: unknown): value is ApiResponse {
  return (
    typeof value === 'object' &&
    value !== null &&
    'type' in value &&
    (value.type === 'success' ||
     value.type === 'error' ||
     value.type === 'warning')
  );
}

/**
 * Helper to safely extract the payload when we know we have a success response.
 * The discriminator guarantees that `payload` exists and is of the expected type.
 */
export function getPayload(response: ApiResponse): T {
  if (response.type !== 'success') {
    throw new Error(`Unexpected response type: ${response.type}`);
  }
  return response.payload;
}

Notice how the discriminant typegetPayload'success'payload

Putting It All Together

When I fetch user data, I can now write:

// 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);
};

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: getPayload

Tip: Keep the discriminant property name consistent across your project. A common choice is kind or type

Common Pitfalls and Best Practices

  • Mismatched discriminators – If a variant omits the discriminant property, TypeScript will not be able to narrow the union, leading to any-like behavior. Always enforce the property in each variant.
  • Literal vs string – Using a literal (`'success'`) is preferred over a plain string because it prevents accidental matches from values like `'success '` or `'success'` with different casing.
  • Optional discriminators – When the discriminant can be missing, consider using a union of { type?: 'success' ... } and a separate null case. This keeps the type hierarchy flat.
  • Extending unions – Adding a new variant later is safe as long as you also update any type guards that perform exhaustive checks. Tools like TypeScript's `exhaustive-type-checking` can help you catch missed branches.

Wrapping Up

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.