Discriminated Unions in TypeScript for State and API Response

1. Overview

Many UI bugs stem from states that should never exist. For example, isLoading is true but error also has a value. Or data is undefined even though the request says it succeeded.

This article teaches you how to use TypeScript’s discriminated union to model data-loading state and API responses. After reading, you’ll know how to:

  • define a union with a discriminant field,
  • let the compiler automatically narrow types,
  • enforce handling every branch with exhaustiveness checks,
  • validate responses at runtime using Zod,
  • write tests for state transitions.

2. The problem with “bags of optional fields”

The familiar approach is to throw everything into an interface with optional fields:

interface OrderState {
  isLoading: boolean;
  error?: string;
  order?: { id: string; total: number };
}

const state: OrderState = {
  isLoading: true,
  error: "Timeout",
  order: { id: "A1", total: 120 },
};

The code above compiles fine. Yet it represents a meaningless state: simultaneously loading, errored, and has data.

With three fields like this, you have 2 × 2 × 2 = 8 combinations. In practice, only about 4 are valid. When reading the state, you also have to write state.order?.total or state.order! everywhere. When there’s an error, the compiler can’t help.

3. What is a discriminated union

A discriminated union is a union of multiple object types. Each type shares a field with a different literal type. This field is called the discriminant, or “tag”:

type Order = { id: string; total: number };

type OrderState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; order: Order }
  | { status: "error"; message: string };

Here status is the discriminant. Each branch only declares fields meaningful to itself. The success branch has order, the error branch has message. A state “loading and also errored” is now impossible to represent:

const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.

3.1. Narrowing by discriminant

When you check the discriminant’s value, TypeScript automatically narrows the type in the corresponding branch:

function describe(state: OrderState): string {
  if (state.status === "success") {
    return `Order ${state.order.id}: ${state.order.total}đ`;
  }
  if (state.status === "error") {
    return `Error: ${state.message}`;
  }
  return state.status === "loading" ? "Loading..." : "Not started";
}

console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
Order A1: 120đ
Error: Timeout

Inside the if (state.status === "success") branch, the type of state is { status: "success"; order: Order }. Thus you can access state.order.id directly, without ?. or !. If you type state.message in this branch, the compiler will report an error.

4. Exhaustiveness check: don’t miss any state

A union only becomes truly useful when adding a new branch causes the compiler to highlight everywhere that branch isn’t handled. A common approach is to use switch combined with the never type:

function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}

function render(state: OrderState): string {
  switch (state.status) {
    case "idle":
      return "<button>Load Order</button>";
    case "loading":
      return "<spinner />";
    case "success":
      return `<p>Total: ${state.order.total}đ</p>`;
    case "error":
      return `<p class="error">${state.message}</p>`;
    default:
      return assertNever(state);
  }
}

After handling all four cases, the type of state in the default branch is never. The call assertNever(state) is therefore valid.

Suppose later you add { status: "cancelled" } to OrderState but forget to update render:

error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.

The error appears at build time, before users see a blank screen. If unexpected data still slips in at runtime, say from an untrustworthy API, assertNever will throw a clear error instead of silently returning undefined.

Note: If you enable "strict": true and declare an explicit return type (: string), missing a case also causes “Function lacks ending return statement”. Still, assertNever is worth using. It provides a clearer error message and works even with functions returning void.

5. Modeling API responses

APIs typically return two forms: success or failure. You can define a shared Result type:

type Result<T, E = ApiError> =
  | { ok: true; data: T }
  | { ok: false; error: E };

type ApiError =
  | { kind: "network"; cause: string }
  | { kind: "http"; status: number }
  | { kind: "invalid"; issues: string[] };

The discriminant doesn’t have to be a string. ok: true and ok: false are two literal types of boolean, so narrowing still works. ApiError is also a nested discriminated union, distinguished by kind.

5.1. Validate responses at runtime with Zod

TypeScript types vanish after compilation. Writing (await res.json()) as Order only “promises” to the compiler—it doesn’t actually check anything. To ensure data from the server has the right shape, validate it with Zod:

npm install zod

Say the server returns a payload with its own discriminant field called type:

import { z } from "zod";

const OrderSchema = z.object({ id: z.string(), total: z.number() });

const OrderResponseSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("found"), order: OrderSchema }),
  z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);

type OrderResponse = z.infer<typeof OrderResponseSchema>;

z.discriminatedUnion("type", [...]) creates a schema based on the type field. Zod reads the type value first, then validates according to the right branch, giving more accurate error messages than z.union. z.infer generates the corresponding TypeScript type, so you don’t write types twice.

Next is the API-calling function, wrapping all errors in Result:

async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
  let res: Response;
  try {
    res = await fetch(`/api/orders/${id}`);
  } catch (e) {
    return { ok: false, error: { kind: "network", cause: String(e) } };
  }

  if (!res.ok) {
    return { ok: false, error: { kind: "http", status: res.status } };
  }

  const parsed = OrderResponseSchema.safeParse(await res.json());
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, error: { kind: "invalid", issues } };
  }
  return { ok: true, data: parsed.data };
}

This function never throws. Every scenario is expressed in the return type. safeParse itself also returns a discriminated union with success as the discriminant. This is exactly the pattern you’re learning.

The caller handles the result like this:

async function showOrder(id: string): Promise<string> {
  const result = await fetchOrder(id);
  if (!result.ok) {
    switch (result.error.kind) {
      case "network": return "Connection lost, try again later.";
      case "http":    return `Server returned ${result.error.status}.`;
      case "invalid": return "Invalid data.";
      default:        return assertNever(result.error);
    }
  }
  return result.data.type === "found"
    ? `Total: ${result.data.order.total}đ`
    : `Order ${result.data.orderId} not found`;
}

After the line if (!result.ok) { ... }, TypeScript knows result must be the ok: true branch. So result.data is accessible directly.

6. Transforming state with a reducer

Discriminated unions pair well with reducers, like React’s useReducer or a custom store. Both state and action are unions:

type OrderAction =
  | { type: "FETCH" }
  | { type: "RESOLVE"; order: Order }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case "FETCH":
      return state.status === "loading" ? state : { status: "loading" };
    case "RESOLVE":
      return state.status === "loading"
        ? { status: "success", order: action.order }
        : state;
    case "REJECT":
      return state.status === "loading"
        ? { status: "error", message: action.message }
        : state;
    case "RESET":
      return { status: "idle" };
    default:
      return assertNever(action);
  }
}

This reducer encodes state-transition rules. RESOLVE only works when loading. This way, a response arriving late after RESET is ignored. You don’t need extra flags.

7. Writing tests for core behavior

This example uses Vitest. Its expect syntax resembles AssertJ and Jest:

npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";

describe("orderReducer", () => {
  const order = { id: "A1", total: 120 };

  it("transitions from loading to success on RESOLVE", () => {
    const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "success", order });
  });

  it("ignores late RESOLVE when already idle", () => {
    const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "idle" });
  });

  it("render displays error message", () => {
    expect(render({ status: "error", message: "Timeout" }))
      .toBe('<p class="error">Timeout</p>');
  });
});
npx vitest run
 ✓ order.test.ts (3 tests)
   ✓ orderReducer > transitions from loading to success on RESOLVE
   ✓ orderReducer > ignores late RESOLVE when already idle
   ✓ orderReducer > render displays error message

 Test Files  1 passed (1)
      Tests  3 passed (3)

The second test is the most important. It locks in race-condition prevention, a bug type that the “optional field bag” model easily falls into.

8. Common mistakes and when not to use

Discriminant gets widened to string. When you create an object in an intermediate variable, TypeScript may infer the type string instead of a literal:

const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.

You have three fixes: declare the type upfront (const s: OrderState = ...), use as const, or use satisfies OrderState.

Early destructuring breaks the connection. If you write let { status, ...rest } = state, TypeScript won’t know which branch rest belongs to when you check status. Narrowing through destructuring only works when the variable is declared with const and never reassigned.

Using the in operator instead of the discriminant. Checking "order" in state still narrows. However, this breaks easily when two branches share a field name. Always check via the discriminant.

Discriminant is optional or mutable. Don’t declare status?: .... Don’t assign state.status = "error" to an existing object. Instead, create a new object for each state. You can mark the field readonly to make the compiler enforce this rule.

Casting instead of validating. Writing as OrderResponse for network data removes all the benefits above. At system boundaries, always validate data at runtime.

Note: Don’t use discriminated unions when fields are truly independent. For example, a form with email?, phone?, note? can be filled in any combination. Optional fields are the right model there. Forcing them into a union only multiplies branches without adding safety.

9. Conclusion

Discriminated unions turn invalid states into compile errors. You’ve seen how to:

  • define a union with a literal discriminant,
  • let TypeScript automatically narrow by the discriminant,
  • use assertNever to enforce handling every branch,
  • model API responses with Result and validate data with z.discriminatedUnion,
  • apply the same pattern to reducers and verify with tests.

The short rule is: if fields only make sense together, put them in the same union branch. Next time you’re about to write isLoading alongside error? and data?, try replacing them with a single status field instead.

typescriptdiscriminated uniontype safetystate managementapi