← Back to blog

Developers: 3 Boundary Patterns for Discriminated Union Responses

September 24, 2026
Developers: 3 Boundary Patterns for Discriminated Union Responses

Model API responses as discriminated unions with a required literal discriminant, validate the raw JSON at the boundary, then let TypeScript's exhaustiveness checks catch missing handlers before they ship. That is the entire strategy. Everything else in this guide is implementation detail: how to shape the union, how to validate it with Zod, and how to keep the compiler honest as your API evolves.


TL;DR:

  • Discriminated unions with a required literal discriminant prevent invalid states and ensure exhaustive handling of API response types, including success, error, and network failure.
  • Validating server responses with a schema library like Zod's discriminatedUnion guarantees payload shape correctness before processing, reducing runtime errors and simplifying debugging.
  • Using switch statements with assertNever enforces compile-time exhaustiveness, catching unhandled response variants early and making the code base more resilient to API changes.
  • Separating transport errors, API errors, and successful responses into distinct union variants enhances clarity, supports tailored retry logic, and improves structured logging.
  • Applying standardized response patterns across API primitives ensures consistent, type-safe handling of failure modes, which is especially critical in AI data pipelines and web monitoring tools.

Gyrence
gyrence.com
Build With Failure-Aware Web Data
Gyrence returns typed responses, including failure cases, so AI agents and data pipelines can reason about web results instead of guessing.
Explore Gyrence

Table of Contents

What Is a Discriminated Union in TypeScript?

A discriminated union is a set of object types that share one property holding a distinct string or number literal. Check that property, and TypeScript narrows the entire object to the matching shape. This is the mechanism behind narrowing in the TypeScript Handbook, and it's the cleanest way to model a value that can legitimately be one of several different things.

For API responses, the pattern looks like this:

type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: { code: string; message: string } };

The status field is the discriminant. Write if (response.status === 'success'), and inside that block TypeScript knows response.data exists and response.error doesn't. No optional chaining, no type assertions, no guessing. The compiler has already done the work of telling you which fields are safe to touch.

Why Discriminated Unions Fit API Responses Better Than Anything Else

Most hand-rolled response types make the classic mistake: { data?: T; error?: Error }, both optional. That shape allows four states, including the two nonsensical ones where both fields exist or neither does. A discriminated union collapses that to exactly the states that can actually occur.

It also forces a distinction that matters operationally: a transport failure (the network dropped, the server returned a 500) is a different problem from an API-declared error (the server responded fine but told you the request was invalid). Union response types let you branch on that difference instead of writing one bloated error handler that tries to cover both.

The real payoff shows up during change. Add a new variant, say rate_limited, and every switch statement missing that case fails to compile. You find out at build time, not when a user hits it in production.

Modeling Patterns for API Response Envelopes

Three shapes handle most real-world cases, and each solves a specific problem at the network boundary.

  1. The envelope pattern. Wrap every response in a consistent shell with a discriminant field (status or kind) and variant-specific payload fields. This is the shape from the earlier ApiResponse<T> example, and it should be your default unless you have a reason to deviate.
  2. The result-type pattern. Borrow from Rust and F# with type Result<T, E> = { kind: 'ok'; value: T } | { kind: 'err'; error: E }. This works well when a function (not just an HTTP call) can fail, and you want the same shape for internal logic and network responses.
  3. Explicit transport-error variants. Add a third branch, distinct from both success and API error, for the request never completing: { status: 'network_error'; cause: unknown }. This matters because a failed fetch and a 400 response require different retry logic, and folding them into one "error" bucket erases that distinction.

The sequence that ties these together at the boundary is consistent regardless of which shape you pick: fetch the resource, treat the body as raw, unknown JSON, run it through a validator, then branch on the validated discriminant. Skip the validation step and you've built a type-safe illusion on top of an unverified assumption.

Validating Response Data With Zod's discriminatedUnion

response.json() returns a promise that resolves to whatever the server sent. It does not check that the payload matches your ApiResponse<T> type. According to MDN's Response documentation, response.ok tells you the HTTP status was in the 2xx range, and response.json() parses the body. Neither guarantees the shape matches your TypeScript interface. Treat the result as unknown until proven otherwise.

This is where a schema library earns its place in the stack. Zod's discriminatedUnion takes the discriminant key and a list of object schemas, then picks the right schema based on that field's value:

const ResponseSchema = z.discriminatedUnion('status', [
  z.object({ status: z.literal('success'), data: z.unknown() }),
  z.object({ status: z.literal('error'), error: z.string() }),
]);

const parsed = ResponseSchema.safeParse(await response.json());
if (!parsed.success) {
  // handle malformed payload
}

safeParse never throws. It returns an object with a success flag, so you branch on that before touching parsed.data. Because Zod already knows which schema applies from the discriminant, it skips checking every branch and produces validation errors scoped to the matching variant, which makes debugging a malformed payload considerably faster than sifting through generic union errors.

Narrowing, Exhaustiveness, and Destructuring Pitfalls

A switch on the discriminant is the standard way to handle every variant of a response type:

switch (response.status) {
  case 'success':
    return renderData(response.data);
  case 'error':
    return renderError(response.error);
  default:
    return assertNever(response);
}

The assertNever helper, drawn from the TypeScript Handbook's guidance on unions and intersections, takes a parameter typed as never and throws if it's ever actually called. If every case is handled, TypeScript proves the default branch is unreachable at the type level. Add a new response variant without updating the switch, and the compiler flags the assertNever call as an error, because the parameter is no longer never.

Destructuring can undo this safety if you're not careful. TypeScript 4.6 clarified how control-flow narrowing behaves with destructured discriminated unions, and the practical rule is simple: const { status, data } = response preserves the relationship between the discriminant and its payload, but mutating those bindings or destructuring into separate variables across branches can lose the narrowing the compiler otherwise gives you for free. When in doubt, narrow on the original object before pulling fields off it.

Pro Tip: Add the @typescript-eslint/switch-exhaustiveness-check rule to your lint config. It flags a switch on a union type that's missing a case, catching the mistake in your editor instead of waiting for a CI build to fail on the assertNever branch.

A Repeatable Boundary Pattern for Production APIs

The pattern that scales across a team looks like this: parse the raw response, validate it against a schema, narrow on the validated discriminant, then transform the result into a plain domain object before it touches business logic. Centralize steps one and two in a single module. Every other part of the codebase should only ever see the validated union, never the raw fetch response.

Four-step validated response boundary pattern

This is the same principle Gyrence applies at the API layer: every call to Search, Traverse, Fetch, Extract, or Map returns a typed, discriminated-union response, including the failure cases, so a caller can branch on status instead of wrapping every request in a try/catch and hoping the error shape is predictable. Structured failure modes are only useful if the type system actually enforces that callers handle them, which is the point of building the union at the boundary rather than bolting error handling on afterward.

Transform early, too. Don't let the validated network shape leak into your UI layer. Map it into a smaller domain type immediately after validation succeeds, the same way you'd map a database row into a model.

A Checklist for PR Reviews

Copy this into your team's review template for any code that touches an API boundary:

  • Every response variant carries a required literal discriminant. No optional discriminants, ever.
  • Transport errors, parse failures, and API-declared errors are three distinct variants, not one catch-all.
  • Raw JSON is validated with a discriminated-union-aware schema before it reaches business logic.
  • Every handler that switches on the discriminant is exhaustive, backed by assertNever or an explicit return type.
  • No object has optional data and error fields sitting side by side.
PracticeRisk if skipped
Literal discriminant on every variantNarrowing fails silently; fields become optional everywhere
Separate transport vs. API error variantsRetry logic and UI messaging get conflated
Schema validation at the boundaryMalformed JSON crashes deep in application code
Exhaustive switch with assertNeverNew variants break silently instead of at compile time
No optional data/error pairImpossible states become representable and reachable

Error Handling Strategies for Discriminated Union Responses

Treat every failure as data, not an exception. A well-modeled response union already separates transport errors from API-declared errors, so error handling becomes a matter of branching correctly, not catching broadly.

Transport failures, network drops, timeouts, DNS errors, belong in a network_error variant with whatever context you can capture (a cause, a retry count, a timestamp). These are usually retryable. API-declared errors, the server responded but rejected the request, belong in an error variant carrying a machine-readable code and a human-readable message. These are rarely retryable without changing the request itself, so your handler logic should treat them differently.

Resist the urge to throw exceptions for expected failure states. If a 404 or a validation error is a normal outcome your API documents, model it as a union variant, not a thrown error caught three layers up. Reserve actual throw statements for truly exceptional situations: a bug, a violated invariant, something no caller should ever need to handle gracefully.

Logging benefits from this structure too. Because every failure variant carries a known shape, you can log response.error.code or response.cause directly, without string-parsing an error message to figure out what went wrong. That structured shape is also what makes a failure useful for retries or alerting: a rate_limited variant can carry a retryAfter field the caller reads directly, instead of parsing it out of a header or an error string.

Discriminated Unions vs. Other API Response Patterns

Discriminated unions aren't the only way to model a response, and it's worth knowing what you're giving up with the alternatives.

A plain union without a discriminant, type Response = SuccessData | ErrorData, forces TypeScript to narrow based on structural checks like 'data' in response. This works but it's fragile. If two variants happen to share a field name, narrowing can behave unexpectedly, and the compiler gives you far weaker exhaustiveness guarantees, because there's no single property to switch on.

The optional-fields envelope, { data?: T; error?: Error }, is the pattern most teams reach for first because it's the least effort to write. It's also the pattern most responsible for null-check sprawl throughout a codebase, since every consumer has to independently decide what "both undefined" or "both present" means.

Class-based polymorphism (separate SuccessResponse and ErrorResponse classes with a shared base) can work, but it trades TypeScript's structural narrowing for instanceof checks and loses the compiler's ability to prove exhaustiveness with a plain never check unless you build that machinery yourself.

The discriminated union sits at the sweet spot: minimal runtime overhead, native compiler support, and a single literal field that both a switch statement and a validation library like Zod can key off. It's the pattern the TypeScript Handbook itself uses to demonstrate narrowing, which is a reasonable signal that it's the idiomatic choice rather than one option among equals.

Performance Considerations for Discriminated Union Responses

Discriminated unions are a compile-time construct. They add zero runtime overhead beyond the plain object you'd have written anyway. The literal discriminant field is just a string or number sitting on the object, no different in cost from any other property.

The actual performance cost, if there is one, comes from validation, not from the type system. Running a schema like Zod's discriminatedUnion against every incoming response adds a parsing step that a raw type assertion wouldn't. In practice this cost is small: Zod's discriminated-union validator uses the discriminant to select the matching schema immediately, rather than testing the payload against every possible variant in sequence, so validation time scales with the size of one schema, not the size of the whole union.

Where performance actually matters is deciding how much of your payload to validate. Full-payload validation on every field of a large response can be unnecessary overhead if most of that payload is passed straight to a UI without inspection. A reasonable middle ground validates the discriminant and the fields your application logic actually branches on, then treats the rest as an opaque, typed pass-through. That's a judgment call based on payload size and how much you trust the source, not a rule to apply everywhere.

The bigger performance risk is skipping validation for the sake of speed and paying for it later. An unvalidated payload that quietly doesn't match its type can pass through several layers of application code before failing somewhere unrelated to its actual source, and debugging that costs far more engineering time than the milliseconds a validation pass would have added.

Performance Considerations for Discriminated Union Responses — overview diagram

Testing Discriminated Union Responses for Coverage and Correctness

Test each variant of the union independently, and test the switch handler's exhaustiveness separately from the individual branch logic.

For unit tests, construct a fixture for every variant your API can return: success, each distinct error code, and at least one malformed-payload case. Assert that your handler produces the correct output for each fixture, and assert that the type narrows correctly, not just that the runtime output is right. A test that only checks the final rendered output can pass even if the narrowing itself is fragile.

Exhaustiveness deserves its own safety net, separate from runtime tests. The assertNever pattern already catches missing cases at compile time, so treat a compiler error on that line as a test failure in CI, not just a developer inconvenience caught locally. Pairing that with the @typescript-eslint/switch-exhaustiveness-check rule means a missing case gets flagged twice, once by the linter during development and once by the type checker if the lint step gets skipped.

For the validation layer, write tests that feed intentionally malformed JSON, wrong discriminant values, missing required fields, extra unexpected fields, through your Zod schema and assert safeParse returns success: false. This is often the most neglected part of the test suite, because it's tempting to only test the happy path where the API behaves as documented. Real APIs drift, ship bugs, and occasionally return HTML error pages where JSON was expected, and your validation layer is the only thing standing between that drift and a runtime crash three layers deep in your application.

What Teams Get Wrong About Discriminated Unions in Practice

The most common mistake isn't choosing the wrong pattern. It's choosing the right pattern and then quietly undermining it: adding an optional field "just for this one edge case," skipping validation because "we trust our own backend," or destructuring a response early and losing the narrowing that made the union useful in the first place.

The operational payoff is easy to underestimate until you've felt the alternative. Explicit failure modes, separate variants for transport errors versus API-declared errors, mean your logging and alerting can distinguish a network blip from a genuine API bug without a human reading stack traces to figure out which one happened. That distinction alone tends to cut down on-call noise more than any dashboard redesign.

If there's one habit worth adopting immediately, it's this: resist the temptation to make the discriminant optional "just this once." The entire pattern's value depends on that field always being there.

— Glen

A Managed API That Returns Typed, Failure-Aware Responses by Default

Everything this article recommends, a literal discriminant, boundary validation, and no ambiguous null states, is easier to enforce when the API you're calling was built the same way. Gyrence's five primitives (Search, Traverse, Fetch, Extract, and Map) each return a typed, discriminated-union response, including the failure cases, so you're not reverse-engineering an error shape from inconsistent status codes or nested optional fields.

Gyrence

That matters most when you're pulling web data into an AI agent or a data pipeline, where a silent malformed response can corrupt everything downstream of it. If you're monitoring a page for changes rather than fetching it once, WebDoppler applies the same structured, typed approach to drift detection and webhook alerts. Gyrence's Standard plan starts at $75 per month, with a Founders and Pay-As-You-Go option for teams that want to test the response shapes before committing to a workspace tier. A managed API is not the right call for every team, but if you're already writing the boundary pattern this article describes, it's worth checking whether Gyrence has already done that work for the sources you're pulling from.

Docs Worth Bookmarking

  • Narrowing — TypeScript Handbook: the canonical reference for how discriminant checks narrow union types.
  • Unions and intersections — TypeScript Handbook: covers exhaustiveness checking and the assertNever pattern.
  • Response - Web APIs | MDN: the authority on what response.json() and response.ok actually guarantee.
  • API — Zod: discriminatedUnion: the schema-library reference for validating discriminated unions at runtime.
  • Type API Responses in TypeScript Safely: a practical walkthrough of the parse, validate, narrow, transform pattern.

Sources

FAQ

What Are Discriminated Unions?

A discriminated union is a set of object types sharing one literal property, called the discriminant, whose value tells TypeScript which shape you're dealing with. Checking that property, usually status or kind, narrows the object to the matching variant, so the compiler knows exactly which fields are safe to access, as described in the TypeScript Handbook.

How Do You Use Discriminated Unions in TypeScript?

Define a union type where each member has a shared literal field, then narrow with a switch or if statement checking that field. Pair it with an assertNever helper in the default branch, per the Handbook's guidance on unions and intersections, so adding a new variant later triggers a compile error anywhere it isn't handled.

How Do I Validate a Discriminated Union at the API Boundary?

Treat response.json() as unknown and run it through a schema like Zod's discriminatedUnion, then check safeParse(raw).success before touching any fields. This confirms the runtime payload actually matches your TypeScript type instead of assuming it does.

What's the Difference Between a Transport Error and an API Error?

A transport error means the request never completed, a dropped connection or timeout, while an API-declared error means the server responded but reported a problem, like invalid input. Modeling them as separate discriminated-union variants lets your handler apply different retry and logging logic to each.

Does Gyrence Use Discriminated Union Responses?

Yes. Every Gyrence API call across its Search, Traverse, Fetch, Extract, and Map primitives returns a typed, discriminated-union response that includes explicit failure variants, so calling code can branch on a literal status field instead of guessing at an inconsistent error shape.