Type-safe states instead of boolean flag soup
Modeling application state with several independent boolean flags risks contradictory states at runtime, such as being loaded and failed at the same time. Discriminated unions solve this by giving TypeScript a shared tag field that automatically identifies the exact state, so impossible combinations are ruled out at compile time and the editor only suggests fields that are actually valid.
Table of Contents
- 1. Boolean flag soup: the problem with independent state flags
- 2. The discriminant: a shared tag field as a compass
- 3. Narrowing: how TypeScript narrows the type automatically
- 4. Practical example: an async data fetch as a state machine
- 5. Exhaustiveness checking with the never type
- 6. Discriminated unions in reducer and action patterns
- 7. Nesting and combining multiple discriminated unions
- 8. Real-world use: API responses, form state, UI state machines
- 9. Boolean flags vs. discriminated unions compared
- 10. Summary
- 11. FAQ
1. Boolean flag soup: the problem with independent state flags
In many React and Vue applications, state modeling starts with independent boolean flags: isLoading, isError, isSuccess, plus optional fields like data and error. At first glance this looks pragmatic, but each of these flags can be set independently of the others. Nothing in the type system prevents isLoading and isError from both being true at the same time, even though that state is logically impossible. This exact pattern is known as boolean flag soup: a growing number of loose flags whose valid combinations only exist in the developers' heads, not in the code.
The consequences usually surface only at runtime. A forgotten reset of isError when reloading leads to a spinner and an error message being visible at the same time. A data field that is theoretically always optional forces every component to add a null check, even when isSuccess has long been true. With every additional flag, the number of theoretically possible combinations grows exponentially, while only a handful of them are actually meaningful. TypeScript can barely help here, because the flags are declared as independent properties with no structural relationship to one another.
2. The discriminant: a shared tag field as a compass
The way out of flag soup is a single shared tag field, the discriminant, usually called status, type, or kind. Instead of several independent booleans, you define a union of multiple object types, where each type carries its own literal value for that field and only contains the fields that actually exist in that state. The loading state has no data field, the error state has no data field, only success carries the actual payload.
What matters is that the discriminant must be a literal type, meaning the concrete string "success" rather than the general type string. Only literal types let TypeScript narrow the set of still-possible union members when comparing against a concrete value. This structure makes impossible states literally unrepresentable: there is no object literal that has both status: "loading" and a data field, because the type simply doesn't allow it. The compiler becomes the guardian of state consistency, rather than a code review or a test case catching it later.
// A discriminated union: each variant carries a literal "status" tag
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
// The tag field must be a literal type, not a plain string,
// otherwise TypeScript cannot narrow the union at all.
function describeState<T>(state: RequestState<T>): string {
// Accessing state.data here would be a compile error,
// because "data" only exists on the "success" variant.
return `Current status: ${state.status}`;
}
3. Narrowing: how TypeScript narrows the type automatically
The real comfort feature of discriminated unions is automatic narrowing through TypeScript's control flow analysis. As soon as the compiler sees a condition like state.status === "success" or a case "success" inside a switch statement or an if block, it narrows the type of the variable within that branch down to exactly the matching union member. Inside the case block, TypeScript then knows every field of that branch, with correct types, with no type assertion or manual casting required.
This analysis works not only with switch, but also with simple if-else chains, with logical operators like &&, and even with custom type guards using the result is Foo pattern. It's important that narrowing is tied to the lexical block structure: if the variable is reassigned in between or passed into a closure, TypeScript can lose the narrowing information. In practice, this means evaluating state as locally as possible and avoiding destructuring across several function layers before the tag has been checked.
type PaymentResult =
| { kind: "approved"; transactionId: string }
| { kind: "declined"; reason: string }
| { kind: "pending"; retryAfterMs: number };
function renderPaymentResult(result: PaymentResult): string {
// Inside each case, TypeScript narrows the union based on "kind"
switch (result.kind) {
case "approved":
// result is narrowed to { kind: "approved"; transactionId: string }
return `Payment approved, id ${result.transactionId}`;
case "declined":
// result is narrowed to { kind: "declined"; reason: string }
return `Payment declined: ${result.reason}`;
case "pending":
// result is narrowed to { kind: "pending"; retryAfterMs: number }
return `Retry in ${result.retryAfterMs}ms`;
}
}
function isDeclined(result: PaymentResult): result is Extract<PaymentResult, { kind: "declined" }> {
// A simple if-check on the tag also narrows within the block
return result.kind === "declined";
}
4. Practical example: an async data fetch as a state machine
The classic use case for discriminated unions is an asynchronous data fetch. Instead of managing isLoading, isError, and data separately, you model four clearly separated states: idle, loading, success with the loaded data, and error with the error that occurred. Every component consuming this state only has to ask which of the four states is currently active, not which combination of flags happens to be set right now.
The benefit shows up especially in React components: a chain of if checks on state.status renders exactly one UI per state, and the editor automatically suggests only the fields that actually exist in each branch. Accessing state.data in the loading branch is flagged immediately as a compile error, long before the code ever reaches production. This pattern combines seamlessly with useReducer, state management libraries, or custom hooks, because the state itself stays independent of the rendering framework.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useProductFetch(productId: string): FetchState<Product> {
const [state, setState] = useState<FetchState<Product>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetchProduct(productId)
.then((data) => {
if (!cancelled) setState({ status: "success", data });
})
.catch((error: Error) => {
if (!cancelled) setState({ status: "error", error });
});
return () => {
cancelled = true;
};
}, [productId]);
return state;
}
function ProductView({ productId }: { productId: string }) {
const state = useProductFetch(productId);
// Only one branch can ever render, no contradictory flags possible
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorBanner message={state.error.message} />;
if (state.status === "success") return <ProductCard product={state.data} />;
return <IdlePlaceholder />;
}
5. Exhaustiveness checking with the never type
Discriminated unions reach their full potential only in combination with exhaustiveness checking. A switch statement over the discriminant should always have a default branch that assigns the remaining variable to type never. As long as every case of the union has been handled, the variable is genuinely of type never at that point, because no union member is left over. If a new state is later added to the union, say an additional retry status, without updating the switch, the compiler reports a type error at exactly that spot.
The common implementation is a small helper function assertNever(value: never), which throws an exception at runtime and acts as a type guard at compile time. This pattern turns a potential runtime gap, a forgotten case, into an immediately visible build failure. Especially in large codebases with many consumers of a given state type, this is a significant safety gain, because new variants can no longer be silently ignored and instead force every affected switch to be touched.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
// Helper that only compiles if it is truly unreachable
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// If a new Shape variant is added and not handled above,
// "shape" is no longer "never" here and the build fails.
return assertNever(shape);
}
}
6. Discriminated unions in reducer and action patterns
Redux, useReducer, and comparable state containers are already structurally based on discriminated unions, even though that's rarely called out explicitly. An action is a union of several object types with a shared type field acting as the discriminant, and the reducer is essentially a switch statement over exactly that field. Inside each case branch, TypeScript knows exactly which payload fields belong to that one action, so accessing a field that only exists on a different action is flagged directly as an error.
The advantage over loosely typed action objects with optional payload fields is enormous: without a discriminated union, every reducer branch would have to defensively check for undefined, because the type system can't guarantee which field actually exists for which type. With a cleanly discriminated action union, that overhead disappears entirely, and exhaustiveness checking additionally ensures that a newly introduced action can never slip through unhandled. This combination is one of the main reasons why typed Redux and useReducer codebases tend to produce noticeably fewer runtime bugs in state handling.
type CartAction =
| { type: "ADD_ITEM"; sku: string; quantity: number }
| { type: "REMOVE_ITEM"; sku: string }
| { type: "SET_QUANTITY"; sku: string; quantity: number }
| { type: "CLEAR_CART" };
interface CartState {
items: Record<string, number>;
}
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
// action is narrowed to the ADD_ITEM variant, sku/quantity exist
const current = state.items[action.sku] ?? 0;
return { items: { ...state.items, [action.sku]: current + action.quantity } };
}
case "REMOVE_ITEM": {
const { [action.sku]: _removed, ...rest } = state.items;
return { items: rest };
}
case "SET_QUANTITY":
return { items: { ...state.items, [action.sku]: action.quantity } };
case "CLEAR_CART":
return { items: {} };
default:
return state;
}
}
7. Nesting and combining multiple discriminated unions
Discriminated unions can be nested arbitrarily deeply, which becomes especially relevant for more complex domain models. A state object can itself contain a field that is, in turn, its own discriminated union, for example a form status that, inside its valid branch, further distinguishes between different submission states. In such cases, TypeScript narrows step by step: first over the outer discriminant, and then, once you check the inner discriminant inside that branch, over the inner union as well.
Combining two independent unions into a composite type is also possible, for instance when a network status and a permission status jointly determine the visible UI state. Some caution is warranted here: naively crossing two unions with four states each theoretically produces sixteen combinations, of which usually only a few make business sense. In such cases it's typically cleaner to define a single, explicit discriminant for the combined state, rather than mixing two unions unchecked in the same object and pushing the combinatorics onto the consumers.
8. Real-world use: API responses, form state, UI state machines
In practice, discriminated unions show up in almost every non-trivial TypeScript codebase once you start looking for them deliberately. API response envelopes benefit especially: a backend that returns either { ok: true; data: T } or { ok: false; error: ApiError } can be modeled as a union without any loss, forcing every call-site to handle the error case explicitly instead of optimistically assuming data is present.
Form state is another core example: pristine, touched, validating, and a submitted state with either success or validationErrors can be cleanly represented as a single union, instead of juggling isDirty, isValidating, and errors as loose flags. UI state machines for modals, wizards, or multi-step forms benefit just as much: a modal state with closed, plus open with a mode field for create or edit, makes it immediately clear which props actually exist in which mode. WebSocket connection status, file upload progress, and feature flag evaluation can likewise be modeled far more robustly with the same pattern than with classic flag combinations.
9. Boolean flags vs. discriminated unions compared
The difference between boolean flag soup and discriminated unions can be pinned down to a handful of concrete criteria. The table below contrasts both approaches for the typical problem areas.
| Criterion | Boolean flags | Discriminated union |
|---|---|---|
| Loading state | isLoading as a separate flag, independent of data | Dedicated loading state with no data field |
| Error handling | error is usually optional, null checks needed everywhere | error only exists on the error branch, no null check needed |
| Impossible states | isLoading and isError can both be true at once | Structure rules out contradictory states |
| Switch exhaustiveness | No mechanism, missing cases go unnoticed | never fallback forces handling of new states |
| IDE autocomplete | Editor suggests fields from all states combined | Editor suggests only fields of the current state |
Overall, the discriminant shifts responsibility for consistency from the developer to the compiler. Instead of disciplined conventions that have to be enforced in code review, TypeScript automatically checks on every build whether all states are handled correctly.
Mironsoft
TypeScript architecture, state management, and type-safe frontend development for Magento and Hyvä projects
Ready for more robust state modeling in your frontend?
We review your TypeScript codebase for boolean flag soup, unsafe state models, and missing exhaustiveness checks, and refactor critical spots into cleanly discriminated unions that surface bugs at compile time.
TypeScript code review
Analyzing your state models for boolean flag soup and missing discriminants
State management refactoring
Migrating to discriminated unions in reducers, hooks, and stores
Type-safe frontend architecture
Exhaustiveness checks and strict typing baked firmly into the CI pipeline
10. Summary
Discriminated unions solve the core problem of boolean flag soup by replacing independent flags with a single literal tag field. TypeScript's control flow analysis uses this discriminant to automatically narrow the type inside switch and if blocks, so each branch only knows the fields that actually exist. Combined with exhaustiveness checking via the never type, every newly added variant is automatically forced to be handled everywhere it's relevant, instead of silently slipping through.
The pattern isn't an academic exercise, it's the structural foundation of Redux reducers, async state hooks, and robust API response models. Anyone still modeling state in TypeScript with several independent booleans is giving up a substantial share of the type safety the compiler could otherwise provide. Switching to discriminated unions rarely requires a large rewrite, usually it's enough to gradually add a shared tag field to existing state interfaces and update consumers accordingly.
Discriminated Unions - The Essentials at a Glance
One tag per state
A literal field like status or type replaces multiple independent booleans.
Narrowing instead of type casts
switch and if narrow the type automatically, with no as or manual assertions.
Exhaustiveness with never
An assertNever fallback turns forgotten states into build failures instead of runtime bugs.
Reducers & nested unions
Redux actions and complex domain models benefit from the same discriminant pattern.