Skip to content

From isLoading and isError to One Discriminated Union

isLoading, isError, and data as three separate booleans let you represent impossible states. A discriminated union makes those states unrepresentable.

· · 8 min read
A computer with a keyboard on a desk

Quick Take

I inherited a component with isLoading, isError, and data as three independent booleans, which meant isLoading and isError could both be true at once, a state that made no sense and happened anyway.

A discriminated union is a union type whose members all share one property, the discriminant, set to a different literal value in each member. Checking that property tells TypeScript exactly which member you're holding. The bug report that sent me looking for it was simple: a spinner and an error message showing at the same time. The cause was three independent booleans on a fetch state object, isLoading, isError, isSuccess, none of which prevented the other two from also being true. TypeScript didn't catch it because nothing about that shape was actually invalid, to the compiler.

Quick take: Three booleans (isLoading, isError, data) can represent sixteen combinations when only three are valid, and TypeScript cannot stop the other thirteen. A discriminated union replaces them with one status property carrying a distinct literal per state, so the impossible combinations stop being representable.

What Does the Boolean Soup Version Look Like?

interface FetchState<T> {
  isLoading: boolean;
  isError: boolean;
  error?: string;
  data?: T;
}

function ProductList({ state }: { state: FetchState<Product[]> }) {
  if (state.isLoading) {return <Spinner />;}
  if (state.isError) {return <ErrorMessage message={state.error} />;}
  // TypeScript still thinks state.data might be undefined here,
  // nothing in the type actually guarantees it exists at this point
  return <ul>{state.data!.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Boolean soup is an interface where several independent flags describe one process, and the state.data! non-null assertion above is the tell that it has taken hold. The type system genuinely can't guarantee data exists just because isLoading and isError are both false, because nothing in the type links those three fields together. You know the intended relationship. TypeScript doesn't.

Count the representable values and the problem is arithmetic rather than opinion. Two booleans plus two optional fields give sixteen combinations, of which three are states this component should ever be in. The other thirteen compile perfectly and each one is a bug waiting for the right sequence of setState calls.

The non-null assertion is how teams paper over it, and it works right up until an early return gets added above it, or an error path sets isError back to false without populating data.

What Does the Discriminated Union Version Look Like?

type FetchState<T> =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };

function ProductList({ state }: { state: FetchState<Product[]> }) {
  if (state.status === 'loading') {return <Spinner />;}
  if (state.status === 'error') {return <ErrorMessage message={state.error} />;}
  // TypeScript now KNOWS state.data exists here, no assertion needed
  return <ul>{state.data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

The discriminated union version replaces those flags with one status property carrying a distinct literal per state, and the ! disappears. state.status === 'success' is the discriminant check, and TypeScript narrows state to the third union member on that branch, where data is guaranteed to exist and error doesn't.

There's also no longer a way to construct { status: 'loading', error: 'oops' }. That object matches no member of the union, so TypeScript rejects it at the point of creation rather than three components downstream when someone reads a stale error field.

That shift, from catching bad states at read time to making them unrepresentable at write time, is the entire value of the pattern. Sixteen representable combinations became three, and the three that remain are exactly the three that mean something.

How Do You Check Exhaustiveness With switch?

Exhaustiveness checking works by assigning the narrowed value to never in the default branch, which fails to compile the moment a union member goes unhandled. Discriminated unions pair naturally with switch for exactly this reason:

function renderState(state: FetchState<Product[]>) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'error':
      return <ErrorMessage message={state.error} />;
    case 'success':
      return <ProductGrid products={state.data} />;
    default: {
      const _exhaustive: never = state; // compile error if a case is missing
      throw new Error(`Unhandled status: ${(_exhaustive as FetchState<unknown>).status}`);
    }
  }
}

The const _exhaustive: never = state line is the trick: if you add a fourth union member, { status: 'idle' }, later, and forget to add a case 'idle': branch, state's narrowed type inside default is no longer never, it's the unhandled member, and the assignment to never fails to compile. That's a compile-time reminder to handle the new case, exactly where you'd otherwise discover the gap at runtime instead. TypeScript 5.0 onwards reports it as a plain assignability error on the never line, which reads badly the first time and unmistakably every time after.

A discriminated union makes the impossible combinations impossible to construct, which is a stronger guarantee than any amount of careful boolean-setting discipline.

Share this Post on X Bluesky

How Does This Apply to Form Submission State?

type SubmitState =
  | { status: 'idle' }
  | { status: 'submitting' }
  | { status: 'success'; confirmationId: string }
  | { status: 'failed'; reason: string; canRetry: boolean };

Form submission is the same shape with four states instead of three, and each member carries only the fields that are meaningful for it. confirmationId doesn't exist unless status is 'success'; canRetry doesn't exist unless it's 'failed'.

Compare that to the flat interface it replaces: isSubmitting, isSuccess, isFailed, confirmationId?, reason?, canRetry?. Six fields, three of them optional, and every optional one carries an unwritten note about which combination of booleans makes it meaningful. That note lives in the head of whoever wrote it and nowhere else.

The union version also survives a redesign better. Adding a 'validating' phase means adding one member, and the exhaustiveness check immediately lists every place that needs updating rather than leaving you to grep for isSubmitting and hope.

When Is a Plain Boolean Still Fine?

A plain boolean is still right when the flag is genuinely independent of everything else. Not every boolean pair is boolean soup. A single flag like disabled: boolean on a button has no other state it's entangled with, so wrapping it in a union buys nothing and costs a layer of indirection.

The pattern applies specifically when two or more flags describe mutually exclusive phases of one process. Loading, error, and success are phases of a single fetch, not independent facts that happen to co-occur. disabled and focused on the same button genuinely are independent: a control can be both, or neither.

The test I use takes about five seconds. Write out every combination of the booleans and ask whether each one describes a real situation. If some rows are nonsense, you have phases and you want a union. If every row makes sense, you have independent flags and booleans are correct.

Which Should You Reach For?

Three terms do the work in this pattern, and they get used interchangeably in a way that hides what is actually happening:

  • Discriminant is the shared property every union member declares with a different literal value, conventionally status or kind.
  • Narrowing is what TypeScript does after it sees a check against that discriminant: it discards the members that cannot match.
  • Exhaustiveness is the guarantee that every member has been handled, which the never assignment turns from a convention into a compile error.

Reaching for one or the other comes down to four questions:

  1. Do the flags describe phases of one process, or independent facts? Phases want a union.
  2. Does any field only make sense in some states? An optional field with an unwritten rule attached is the strongest signal.
  3. Are you writing ! or as to convince the compiler a field exists? That is the type system telling you the model is wrong.
  4. Will the set of states grow? Unions scale by one line; boolean soup scales by doubling.

Question three is the one worth internalising, because it catches the problem without you having to think about it. Every ! and every as in state-handling code is a place where you know something the compiler doesn't, and in almost every case the reason it doesn't know is that the type permits combinations you never intended. Fixing the model deletes the assertion as a side effect rather than as a separate cleanup task.

Boolean flagsDiscriminated union
Invalid combinationsCompile fine, must be prevented by disciplineDon't exist as constructible values
Accessing state-specific fieldsRequires ! assertions or manual checksAutomatically narrowed, no assertion needed
Adding a new stateAdd another boolean, update every check siteAdd a union member, switch exhaustiveness flags missed handling
ReadabilitySix-plus loosely related fieldsOne tagged shape per state

Conclusion

The bug that started this, a spinner and an error rendering together, wasn't a logic mistake in one component, it was a type shape that allowed an invalid state to exist in the first place. Discriminated unions don't just organize the code better, they remove the invalid combination from the set of values the type system will accept, which is a stronger guarantee than any amount of careful boolean-setting discipline.

Frequently Asked Questions

What makes a union 'discriminated' in TypeScript?
A discriminated union is a union of object types that share a common property, the discriminant, or tag, where each member has a different literal value for that property. TypeScript uses the discriminant to narrow the type automatically inside an if or switch that checks it, so once you check status === 'success', TypeScript knows the data property exists on that branch and error doesn't, without a separate type assertion.
Why is 'boolean soup' a real bug risk and not just a style preference?
Three independent booleans, isLoading, isError, isSuccess, describe 2 cubed, eight possible combinations, when only 3 or 4 are ever meant to be valid. Nothing in the type system stops isLoading and isError from both being true simultaneously, that state compiles fine and has to be prevented by discipline in every place the state is set, which inevitably slips at some point in a large codebase. A discriminated union makes the invalid combinations not exist as a representable value at all.
Does a discriminated union have runtime cost compared to separate booleans?
No. Discriminated unions are a compile-time-only construct, the discriminant property (often a string literal like status: 'success') is a completely normal object property at runtime, with no special TypeScript machinery generating extra code. The type safety is free, it costs nothing at runtime beyond the one string property you were likely already storing in some form.