modeling error states exhaustively instead of loosely
An error code as a number or a single boolean for the failure case hides from the compiler which error kinds actually exist. Discriminated unions make every possible error variant visible as its own named type and, through exhaustiveness checking, guarantee that none of them are ever forgotten in the code.
Table of Contents
- 1. Why generic error codes fail at state management
- 2. Discriminated unions as the core principle for error states
- 3. Designing an error union type for form validation
- 4. Exhaustiveness checking with switch and never
- 5. Combining discriminated unions with UI states
- 6. Nested error unions for multi step processes
- 7. Discriminated unions versus error classes
- 8. Common mistakes when modeling error unions
- 9. Approaches in comparison
- 10. Summary
- 11. FAQ
1. Why generic error codes fail at state management
Many codebases still handle error states as a loose combination of a boolean and an optional string: { hasError: boolean, errorMessage?: string }. This pattern works on the surface, but it hides everything essential from the type system. The compiler does not know which error kinds even exist, whether a network error needs to be treated differently from a validation error, or whether additional fields are available in the failure case. Discriminated unions solve exactly this problem by letting every error kind exist as its own, clearly named variant in the type system.
A discriminated union for error handling consists of several object types that share a common literal field, often called type or kind. Each variant can carry additional fields relevant only to that variant. A network error might need an HTTP status code, a validation error might need the name of the affected field. Without a discriminated union, all these fields would have to exist as optional properties on a single flat object, which leads to nonsensical states, such as an HTTP status code attached to a pure validation error.
The second major benefit only shows up during processing: TypeScript can check, for a real discriminated union, whether every variant has actually been handled. This feature, known as exhaustiveness checking, is completely ineffective for loose error codes because the compiler has no fixed set of possible values to work with. With a discriminated union, however, a missing case in a switch produces a compiler error as soon as a new variant is added.
2. Discriminated unions as the core principle for error states
The basic pattern of a discriminated union for error handling is simple: a set of object types, each with a shared literal field that TypeScript recognizes as the discriminant. As soon as that field is checked in a condition, TypeScript automatically narrows the type within that branch to the matching variant. This behavior is called narrowing and is the real reason discriminated unions fit error handling so well: access to variant specific fields is type safe, without manual type assertions.
A key design principle is that every error variant should carry exactly the fields that actually make sense for that error kind, no more and no less. A NetworkError needs statusCode and url, while a ValidationError instead needs field and constraint. This precision is the core advantage over a generic error class with many optional properties, where every consuming site has to figure out on its own which fields are set in which case.
In practice, discriminated unions are often combined with the Result type pattern covered in a related article: instead of a single error string, the error type of a Result union is replaced by a dedicated, multi variant discriminated union. That keeps the success case unchanged while the failure case itself gets its own exhaustive structure again.
// A discriminated union of distinct, named error variants
type AppError =
| { type: "network"; statusCode: number; url: string }
| { type: "validation"; field: string; constraint: string }
| { type: "notFound"; resourceId: string }
| { type: "unauthorized"; requiredRole: string };
function describeError(error: AppError): string {
switch (error.type) {
case "network":
// narrowed: statusCode and url are available here
return `Network error ${error.statusCode} calling ${error.url}`;
case "validation":
return `Field "${error.field}" violates constraint "${error.constraint}"`;
case "notFound":
return `Resource "${error.resourceId}" was not found`;
case "unauthorized":
return `Missing role "${error.requiredRole}"`;
}
}
const sample: AppError = { type: "validation", field: "email", constraint: "format" };
console.log(describeError(sample));
3. Designing an error union type for form validation
Form validation is one of the most common use cases for an error related discriminated union, because a single form can typically produce several different error kinds at once. A required field can be empty, a text field can have an invalid format, a number field can fall outside an allowed range. Each of these situations deserves its own variant in the error union, so the UI layer can display the matching message directly instead of parsing a generic string.
A proven pattern is to structure the error union per field kind and then collect an array of these errors per form, instead of allowing only a single error. That lets the UI show every problem at once, instead of sending the user through several rounds of corrections. It matters that every variant of the discriminated union also carries the affected field, so mapping to the right UI component stays unambiguous.
Libraries such as Zod already produce similar structures internally and combine well with a custom, project specific discriminated union: the raw Zod error object gets translated into a leaner union tailored to the application before it reaches the UI layer. That decouples the UI from the concrete validation library and makes a later swap easier.
// Field-level error union tailored to form validation
type FieldError =
| { kind: "required"; field: string }
| { kind: "tooShort"; field: string; minLength: number }
| { kind: "outOfRange"; field: string; min: number; max: number }
| { kind: "invalidFormat"; field: string; pattern: string };
function validateSignupForm(input: {
username: string;
age: number;
email: string;
}): FieldError[] {
const errors: FieldError[] = [];
if (input.username.length === 0) {
errors.push({ kind: "required", field: "username" });
} else if (input.username.length < 3) {
errors.push({ kind: "tooShort", field: "username", minLength: 3 });
}
if (input.age < 18 || input.age > 120) {
errors.push({ kind: "outOfRange", field: "age", min: 18, max: 120 });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
errors.push({ kind: "invalidFormat", field: "email", pattern: "email" });
}
return errors;
}
function fieldErrorMessage(error: FieldError): string {
switch (error.kind) {
case "required":
return `${error.field} is required`;
case "tooShort":
return `${error.field} must be at least ${error.minLength} characters`;
case "outOfRange":
return `${error.field} must be between ${error.min} and ${error.max}`;
case "invalidFormat":
return `${error.field} has an invalid format`;
}
}
4. Exhaustiveness checking with switch and never
The most important practical benefit of a discriminated union for error handling is exhaustiveness checking: when a new error variant is later added to the union but handling it in an existing switch is forgotten, the compiler reports an error instead of letting the problem surface silently at runtime. This mechanism is built on the never type: a default branch that assigns the remaining type of a variable to a function expecting never forces nothing to remain once all cases have been handled.
The implementation is unremarkable but effective: a small helper function assertUnreachable(value: never): never gets called in the default case. As long as every variant has been handled, the type at that point is genuinely never, and the call compiles. If a new variant is added but no additional case gets added, the new, unhandled variant remains at that point, which produces a type error long before the code ever runs in production.
This pattern is especially valuable in teams where several developers work independently on the same error union. Whoever introduces a new error variant immediately gets a list of every place in the code that does not yet handle it, because the compiler throws an error at each of those places. Without exhaustiveness checking, such a missing case would only surface through manual testing or, worse, through a production incident.
// Exhaustiveness checking via a never-typed helper
function assertUnreachable(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
type PaymentError =
| { type: "insufficientFunds"; available: number; required: number }
| { type: "cardDeclined"; reason: string }
| { type: "gatewayTimeout" };
function handlePaymentError(error: PaymentError): string {
switch (error.type) {
case "insufficientFunds":
return `Only ${error.available} available, ${error.required} required`;
case "cardDeclined":
return `Card declined: ${error.reason}`;
case "gatewayTimeout":
return "Payment gateway timed out, please retry";
default:
// If a new variant is added above without a matching case here,
// TypeScript reports a compile error at this exact line
return assertUnreachable(error);
}
}
5. Combining discriminated unions with UI states
Error states rarely exist in isolation, they are part of a larger UI state that also needs to cover loading, success, and empty. A proven extension of the discriminated union pattern is to model the entire loading state of a component as a single union whose error variant in turn holds the specific error union from the previous section. That produces a coherent, exhaustive state machine instead of several independent booleans such as isLoading, hasError, and data.
The decisive advantage over separate booleans: with several independent flags, isLoading and hasError could theoretically both be true at once, a state that usually makes no sense in the application yet the type system never rules it out. A single discriminated union with the variants idle, loading, success, and error structurally excludes such nonsensical combinations, because exactly one variant is ever active.
In Alpine.js components inside a Hyva theme, the same pattern can be implemented with a lean type definition for the x-data state, even though Alpine.js itself carries no type system of its own. The union is defined as a TypeScript type, while the actual logic compiles down to plain JavaScript inside the component. The benefit stays the same: state transitions are exhaustively checked at development time, even though Alpine.js knows nothing about types at runtime.
// A unified UI state as a single discriminated union
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: AppError };
function renderState<T>(state: LoadState<T>, render: (data: T) => string): string {
switch (state.status) {
case "idle":
return "Nothing loaded yet";
case "loading":
return "Loading…";
case "success":
return render(state.data);
case "error":
return `Error: ${describeError(state.error)}`;
}
}
6. Nested error unions for multi step processes
Multi step processes such as a checkout flow or a data import consist of several steps, each of which can produce its own error kinds. A flat discriminated union over every possible error of every step quickly becomes unwieldy. A nested structure works better: each step gets its own small error union, and an outer union combines them with an additional field indicating which step the error occurred in.
This pattern is sometimes called a tagged union of unions: the outer variant carries step as the discriminant, while the inner variant describes the actual error kind within that step. The benefit is double exhaustiveness: TypeScript checks both whether every step has been handled and whether every error kind within each step has been handled, as long as both levels are consistently guarded with switch and the never pattern.
In a Magento GraphQL checkout flow, for example, there are typically the steps cart validation, address validation, shipping method, and payment. Each of these steps has entirely different possible error kinds. A nested discriminated union maps this structure one to one, while the UI layer uses the outer step field to decide which section of the form should display the error message.
7. Discriminated unions versus error classes
A common design question is whether error states should be modeled as a discriminated union of plain objects or as a hierarchy of error classes. Both approaches have their place, but they solve different problems. A discriminated union is ideal when errors need to be treated as pure data, for example to serialize them across a network boundary, store them in a Redux style store, or simply compare them with toEqual in tests.
Error classes, on the other hand, work better when behavior needs to be attached to the error, such as a retry() method or a toHttpResponse() method, and when native JavaScript stack trace capture via Error.captureStackTrace is needed. The drawback of classes: instanceof checks do not work reliably across serialization boundaries, for example when an error is sent to the client as JSON and deserialized there, while a discriminated union as a plain data object crosses that boundary without loss.
In practice, both approaches are often combined: on the server side, error classes exist with behavior, at the API boundary they get translated into a serializable discriminated union, and on the client side, all UI logic works exclusively with that union. That way each side stays with the model best suited to its requirements.
8. Common mistakes when modeling error unions
A common mistake is a discriminant field that is too coarse, lumping several genuinely different error kinds into a single variant. If a variant such as { type: "error"; message: string } covers every possible error, that is essentially the generic error string from section one again, just wrapped in a union. The real benefit of a discriminated union only emerges once genuinely distinct, semantically separable error kinds exist as their own variants.
// WRONG: a single catch-all variant defeats the purpose of a union
type TooGenericError = { type: "error"; message: string };
// WRONG: missing default case silently allows incomplete handling
function badHandler(error: AppError): string {
if (error.type === "network") {
return "network problem";
}
return "unknown"; // validation, notFound, unauthorized all fall through here
}
// RIGHT: exhaustive switch with a never-typed fallback
function goodHandler(error: AppError): string {
switch (error.type) {
case "network":
return `network problem: ${error.statusCode}`;
case "validation":
return `validation problem: ${error.field}`;
case "notFound":
return `not found: ${error.resourceId}`;
case "unauthorized":
return `unauthorized: needs ${error.requiredRole}`;
default:
return assertUnreachable(error);
}
}
A second widespread mistake is using if/else if chains instead of switch to handle a discriminated union. Without the final never branch in a switch's default case, exhaustiveness checking is lost, because a simple else branch without an explicit type check does not force every variant to actually be covered. A third mistake is typing the discriminant as string instead of a literal union, which prevents TypeScript from narrowing to concrete variants.
9. Approaches in comparison
The table below compares three common approaches to error modeling against practically relevant criteria.
| Criterion | Generic Error Code | Discriminated Union | Error Class Hierarchy |
|---|---|---|---|
| Exhaustiveness checking | Not possible | Yes, with switch and never | Only with extra effort |
| Serializability | Yes | Yes, lossless | Only with a manual toJSON method |
| Attaching behavior to errors | No | Only via separate functions | Yes, directly as a method |
| Field specific data | Only as optional fields | Yes, precise per variant | Yes, per class |
| Suitability for UI state | Poor | Very good | Possible, but cumbersome |
For UI states and API boundaries, a discriminated union is almost always the better choice because it stays serializable and directly supports exhaustiveness checking. For internal, server side logic with real behavior per error kind, error classes remain a sensible complement, especially when an existing hierarchy is already in place.
Mironsoft
TypeScript type modeling, state management, and API design
Error states the compiler actually checks?
We model errors and UI states as exhaustive discriminated unions and set up exhaustiveness checking in your existing TypeScript codebase.
Type Audit
Analyze existing error handling and identify union candidates
State Machines
Model UI states as exhaustive discriminated unions
Team Training
Workshops on exhaustiveness checking and type modeling
10. Summary
Discriminated unions solve a fundamental problem of error handling in TypeScript: generic error codes or loose booleans hide from the compiler which error kinds actually exist and which fields are available in which case. A union of clearly named variants with a shared discriminant field makes every error kind explicit and enables type safe narrowing without manual assertions.
The biggest practical win is exhaustiveness checking via switch and the never type: a new error variant that is not yet handled somewhere in the code produces a compiler error instead of surfacing only at runtime or in production. Combined with a unified UI state that models loading, success, empty, and error as a single union, the result is a state machine that structurally excludes nonsensical combinations such as simultaneous loading and error.
Error Handling with Discriminated Unions, the essentials at a glance
Core Principle
A shared literal field distinguishes several error variants, each with its own matching fields.
Exhaustiveness
A never-typed default branch in the switch forces every variant to be handled, otherwise compilation fails.
UI States
Loading, success, empty, and error as a single union instead of several booleans, no more nonsensical combinations.
Boundary
Use discriminated unions for data and API boundaries, complement with error classes for server side behavior.