typescript6 min read

Discriminated unions for API response modeling

Model success, validation errors, and domain failures with TypeScript discriminated unions so frontend and backend agree on what can happen on the wire.

HTTP APIs love to return different shapes for the same status code: sometimes { error: "..." }, sometimes { message, code }, sometimes field-level validation maps. Frontend code compensates with optional chaining and hope. Discriminated unions give you a single response type where a literal kind or status field tells TypeScript which payload fields exist—so product teams ship fewer “undefined is not a function” incidents after backend tweaks.

This post is about modeling API responses in TypeScript at the boundary between server and client. It pairs with API design frontend teams don't hate and Avoiding any: practical patterns.

Why unions beat optional everything

A tempting but fragile pattern:

type ApiResponse<T> = {
  data?: T;
  error?: string;
  errors?: Record<string, string[]>;
};

Callers must guess which fields are set. Refactors do not fail at compile time when backend adds a third failure mode.

A discriminated union (tagged union) looks like:

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: { code: string; message: string } }
  | { ok: false; error: { code: "VALIDATION"; fields: Record<string, string[]> } };

The shared discriminator (ok, or type: "success" | "error") lets switch and if narrow types. That narrowing is the product win: UI branches become exhaustive and reviewable.

The TypeScript handbook section on narrowing explains how control-flow analysis works; use it as the shared language between FE and BE engineers.

Align unions with HTTP semantics

Unions model application outcomes; HTTP status codes model transport. You can still return 200 with { ok: false, error: ... } for expected business failures (card declined, seat limit) while reserving 5xx for truly exceptional cases.

Document the contract:

  • Successok: true plus payload
  • Client mistakes — validation union member or 400 with typed body
  • Auth — 401/403 with stable error codes for session refresh UX (Auth session patterns for SaaS)
  • Not found — distinct member so UI does not treat it as generic toast

Avoid mixing HTML error pages into JSON clients—your union cannot narrow on "<html>".

Designing error members product can name

PMs and support need stable error codes (SEAT_LIMIT, TRIAL_EXPIRED), not only English messages. Messages change for localization; codes drive analytics and help center links.

Each error member should carry:

  • code — machine-stable string
  • message — user-visible default
  • Optional details — structured context safe to log (no secrets)

Frontend maps code to actions: upgrade modal, re-auth, contact support. See API design frontend teams don't hate for naming and versioning discipline.

Validation as its own variant

Field validation is not a string error—it is a map of paths to messages. Give it a dedicated union member so forms can bind errors without string parsing.

On the server, validate once and return the validation variant; do not leak Zod/Yup internals unless you version them. On the client, share a schema package or OpenAPI-generated types if you invest in codegen.

Sharing types across packages

Monorepos often publish @app/api-types consumed by web, mobile, and workers. Keep unions serializable JSON shapes—no Date objects unless you standardize ISO strings and document parsing.

If you use tRPC or similar, unions still matter at integration boundaries (webhooks, third-party callbacks) where RPC helpers end.

Parsing unknown JSON safely

Runtime data is not typed until you validate it. Use schema validators (Zod, Valibot, JSON Schema) to parse unknown into your union before business logic. TypeScript types then describe what the parser enforces.

Without runtime parsing, unions are documentation that lies when a proxy strips fields.

Exhaustive handling in UI

Encourage switch (result.ok) or switch (result.error.code) with default branches that call assertNever in development. New backend codes should cause compile errors where switches are exhaustive—use satisfies or lint rules where helpful.

Loading and idle states are not union members; keep request state (idle | loading | result) separate from result unions to avoid combinatorial explosion.

Versioning new union members

Adding a union member is a breaking change for exhaustive clients unless they use default handling. Prefer:

  • New code values within an existing error shape when possible
  • Explicit API version headers when you must add top-level variants

Coordinate with mobile apps on longer upgrade tails than web.

OpenAPI and codegen

OpenAPI oneOf can express unions; generators vary in quality. If you codegen, verify discriminated mappings and hand-fix edge cases. Manual unions in TypeScript with tests against fixture JSON often beat bad codegen for small teams.

Reference the OpenAPI specification when negotiating contracts with partners—shared vocabulary reduces slack threads.

Testing unions

Fixture tests: golden JSON files per member, parsed and asserted in CI. Snapshot API responses from integration tests against those fixtures.

When backend removes a member, delete fixtures in the same PR so web does not ship dead branches.

Anti-patterns

  • Boolean flags plus optional fields (success: boolean; data?: T; error?: E) without mutual exclusion
  • Throwing exceptions for expected business failures in server actions without mapping to union responses clients can render
  • Using any at the fetch boundary—see Avoiding any: practical patterns

Server Actions and route handlers

Next.js Server Actions blur client/server; return serializable unions from actions and handle them in client components explicitly. Do not rely on thrown Error strings for control flow users should see.

For broader App Router data patterns, App Router mental model for product teams helps place where parsing belongs (server first).

Mapping unions to UI components

Product screens map cleanly to union members: success views, inline validation, toast for generic failures, full-page for auth expiry. Document the mapping in your design system so Figma components name the same variants engineers implement.

For tables and lists, distinguish empty success (data: []) from not found errors—different copy and CTAs. Unions make that distinction type-enforced instead of convention-only.

Logging and analytics on discriminated errors

Log error.code in structured logs and analytics events, not raw user messages. Dashboards by code reveal whether SEAT_LIMIT spiked after a pricing change. Avoid logging validation field values that contain PII unless retention policy allows.

Support tools can search by code if clients send it in error reports users opt into.

Gradual adoption in brownfield APIs

You do not need to unionize every legacy endpoint overnight. Wrap new endpoints first; add adapters that normalize old shapes into unions at the client boundary. Deprecate optional-field responses on a published timeline.

Pair backend changes with API design frontend teams don't hate versioning notes so mobile and web upgrade together.

Webhooks and async outcomes

Webhooks that notify completion of long jobs should use the same discriminated vocabulary as synchronous APIs: event: "job.completed" with result: { ok: true, data } or structured failure members. Consumers then share one TypeScript package for HTTP and webhook payloads.

Document which members can appear async only (for example provider timeout) so clients do not wait on HTTP for outcomes that will never arrive inline.

Keep webhook and HTTP union types in the same package version so deploy order does not desynchronize consumers.

Closing stance

Discriminated unions turn API responses into shared product vocabulary: success, validation, auth, and domain failures are explicit, reviewable types. Invest once at the boundary; every screen that calls the API gets cheaper to build and safer to change.

Let's talk