Stop Using Boolean Flags for State: Master Discriminated Unions in TypeScript
"Wait, isLoading is true AND isError is true?"
I used individual useState for API state.
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
It looked simple. But complex logic led to bugs.
Suddenly, I had isLoading: true and isError: true simultaneously.
The UI showed a Spinner AND an Error Message. A monster UI was born.
"Loading excludes Error! Why are they independent?"
What Confused Me Initially? (Impossible States)
I thought "Splitting state is modular." But independent variables mean Combinatorial Explosion.
isLoading(T/F)isError(T/F)
Possible combinations: 4. Valid combinations: 2. My code allowed 2 "Impossible States" to exist.
The 'Aha!' Moment (Traffic Light Analogy)
I viewed it as a "Traffic Light."
- Bad: Separate switches for Green, Red, Yellow bulbs. You can accidentally turn all on. (Accident)
- Good: A single rotary switch pointing to ONE color.
State should be like a traffic light. Mutually Exclusive.
The Fix: Discriminated Unions
Using TypeScript's Discriminated Unions solves this perfectly.
The key is a common field (discriminant), usually status or type.
Step 1: Define Types
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T } // data exists ONLY here
| { status: 'error'; error: string }; // error exists ONLY here
Now, getting data when status === 'loading' is a compile error.
You cannot write code that accesses invalid data.
Step 2: Update State
const [state, setState] = useState<ApiState<User>>({ status: 'idle' });
// Loading
setState({ status: 'loading' });
// Success (No need to manually reset isError=false. It's a full replacement)
setState({ status: 'success', data: fetchedUser });
Step 3: UI Rendering (Switch Case)
switch (state.status) {
case 'loading': return <Spinner />;
case 'error': return <Error msg={state.error} />;
case 'success': return <User user={state.data} />;
}
Clean logic. No if (!loading && !error && data).
TS ensures you handle all cases (Exhaustiveness Checking).
Deep Dive: With Reducers
This pairs beautifully with useReducer or Redux.
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User };
function reducer(state, action) {
if (action.type === 'FETCH_SUCCESS') {
return { status: 'success', data: action.payload };
}
}
State transitions become explicit and type-safe.
Application: React Query
TanStack Query uses this pattern.
const { status, data } = useQuery(...)
if (status === 'success') {
// data is guaranteed defined
}
Good libraries "Make Impossible States Impossible."
7. Deep Dive: "You Missed a Case!" (Exhaustiveness Checking)
What happens if you forget case 'error': in your switch statement?
TypeScript usually stays silent.
But we want it to scream "Handle every case OR ELSE!"
Enter the assertNever helper pattern using the never type.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
// ...
switch (state.status) {
case 'idle': return ...;
case 'loading': return ...;
case 'success': return ...;
// Oops, forgot 'error'
default:
// RED SQUIGGLY LINE HERE!
// "Argument of type 'ApiState<User>' is not assignable to parameter of type 'never'."
return assertNever(state);
}
Reaching default means there's a leftover type in state (the 'error' state).
assertNever only accepts never (impossible values).
Since state is not never, TS throws a compile error.
With this, if you later add status: 'refetching', the compiler essentially hunts down every switch statement in your app and yells, "Update logic here!"
It's Refactoring Nirvana.
8. Case Study: Complex Payment Flow
For simple fetch, it's nice. For Payments, it's a lifesaver.
The Situation
E-commerce payment page. States are wild:
- Idle: Init.
- Validating: Checking coupons/points.
- Processing: Talking to Payment Gateway.
- Waiting3DS: Banking app opened, waiting for user password (3D Secure).
- Success: Done.
- Fail: Insufficient funds.
Managing this with isValidating, isProcessing, isWaiting3DS booleans?
You end up with "Validating (True) AND Waiting3DS (True)" — a logical impossibility.
Applying Discriminated Unions
type PaymentState =
| { status: 'idle' }
| { status: 'validating'; couponId?: string }
| { status: 'processing'; orderId: string }
| { status: 'waiting_3ds'; redirectUrl: string; transactionId: string } // URL needed ONLY here!
| { status: 'success'; receiptUrl: string }
| { status: 'fail'; reason: string; canRetry: boolean };
Each state carries completely different data.
redirectUrl exists ONLY in waiting_3ds.
Without unions, redirectUrl would be string | undefined everywhere. "Does this exist in validation phase?" Who knows.
With unions, the UI code is crystal clear:
if (state.status === 'waiting_3ds') {
// TS guarantees state.redirectUrl exists!
window.location.href = state.redirectUrl;
}
9. FAQ
Q: Must the field be named status?
A: No. type, kind, tag works too. Just pick a convention. type is common in Redux, status in React Query.
Q: Is it overkill for just 2 states (Success/Fail)?
A: If it's a simple toggle (Open/Close), use isOpen boolean. But if Data is attached (Success has Data, Fail has Error), use Unions. It guarantees safety.
Q: Can't I keep isLoading separate for refetching?
A: For "Refetching while showing old data," you might be tempted to keep isLoading. A better way is to add isRefetching property inside the success state, or define a reloading state that carries oldData. The goal is to make the state Explicit.
Managing state with 4 booleans is a ticking time bomb. Use Discriminated Unions with a status field. Let the compiler block logical errors.
10. Real World Example: Form Validation
Another classic example where Discriminated Unions shine is Form Validation. Often, we see code like this:
type FormState = {
isValid: boolean;
isSubmitting: boolean;
errors: Record<string, string>;
};
This attempts to model everything with loose fields. But consider this:
- Can it be
isValid: truebut haveerrors? - Can it be
isSubmitting: truebutisValid: false?
A better approach is to model the lifecycle of the form:
type FormState =
| { status: 'idle'; values: FormData }
| { status: 'validating'; values: FormData }
| { status: 'submitting'; values: FormData }
| { status: 'success' }
| { status: 'error'; errors: Record<string, string>; values: FormData };
By doing this, you prevent the UI from trying to submit a form that is currently in the error state. The submit button can be disabled effectively because the submitting state is mutually exclusive from error or idle.
11. Conclusion
Switching from "Boolean Soup" to Discriminated Unions is a paradigm shift.
At first, it feels like more typing. defining type ApiState = ... takes more lines than useState(false).
However, the Debt of boolean flags accumulates over time.
- Debugging "Why is the spinner showing on the Error screen?"
- Fixing "Why did the app crash when accessing data that wasn't there?"
- Explaining to new hires "Oh, ignored that variable, it's a legacy flag."
Discriminated Unions pay off this debt upfront. They make your state "Correct by Construction." If it compiles, it likely handles all states correctly.
So next time you reach for setIsLoading(true), stop and ask:
"Is this loading state exclusive to other states?"
If yes, use a Union.