Stop Using Boolean Flags for UI State in TypeScript

Stop Using Boolean Flags for UI State in TypeScript

Tired of messy isLoading and isError flags? Learn how to use TypeScript Discriminated Unions to eliminate impossible states and write bulletproof components.

Have you ever looked at a component's state and seen a mess of boolean flags like isLoading, isError, and isSuccess? I certainly have. It’s one of the most common patterns in frontend development, especially when fetching data. But it's also a trap that leads to fragile code and unpredictable UI bugs.

Today, let's talk about why relying on multiple boolean flags is a bad idea, and how we can use TypeScript's Discriminated Unions to make our state management bulletproof.

The Problem with Boolean Flags

Let’s look at a typical data fetching scenario in a React or Vue application. You might define your state interface like this:

interface UserState {
  data: User | null;
  isLoading: boolean;
  isError: boolean;
  errorMessage: string | null;
}

At first glance, this seems perfectly fine. You set isLoading to true when the fetch starts, and then update data or isError when it finishes.

But what happens if a bug in your logic sets both isLoading: true and isError: true at the same time? Or what if data is populated, but isError is also true from a previous failed request that wasn't properly cleared?

These are called impossible states. Your application should logically never be in a state where it is both actively loading the initial data and displaying a fatal error. However, your TypeScript types explicitly allow it. When your type system allows impossible states, you are forced to write complex, defensive if/else rendering logic in your components just to figure out what is actually happening.

// Good luck maintaining this logic...
if (isLoading && !isError) {
  return <Spinner />;
} else if (isError) {
  return <ErrorMessage message={errorMessage} />;
} else if (data) {
  return <UserProfile user={data} />;
} else {
  return null; // How did we even get here?
}

The Solution: Discriminated Unions

A person facing multiple confusing directional signs representing impossible states

Instead of describing the state with independent booleans that can conflict, we should describe the state as a set of mutually exclusive possibilities. In TypeScript, we achieve this using Discriminated Unions.

A discriminated union is simply a union of object types that all share a common literal property—the "discriminant".

Let's rewrite our UserState to use a status property as the discriminant:

type UserState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; errorMessage: string };

Look at how much clearer this is!

  1. If the status is 'idle' or 'loading', we don't even have access to data or errorMessage.
  2. If the status is 'success', we are guaranteed that data exists.
  3. If the status is 'error', we are guaranteed to have an errorMessage.

We have completely eliminated the possibility of impossible states at the type level.

Using It in Practice

A safety net catching objects, representing TypeScript compiler protection

When you use this pattern, TypeScript becomes incredibly helpful. It narrows down the type based on the status check, providing accurate autocompletion and catching errors at compile time.

Here is how you might handle this state in a component:

function UserProfileComponent({ state }: { state: UserState }) {
  switch (state.status) {
    case 'idle':
      return <p>Ready to fetch user data.</p>;
    case 'loading':
      return <Spinner />;
    case 'error':
      // TypeScript knows 'errorMessage' exists here
      return <Error message={state.errorMessage} />;
    case 'success':
      // TypeScript knows 'data' exists here
      return <Profile data={state.data} />;
  }
}

Notice how we don't need any complex if/else chains checking multiple variables. The logic flows naturally, and the compiler ensures we can't accidentally try to render state.data when the status is 'error'.

Exhaustive Checking for Bulletproof Code

One of the best features of discriminated unions is the ability to perform exhaustive checking. What happens if another developer comes along and adds a new state, like { status: 'revalidating'; data: User }, to the UserState union?

If they forget to update the switch statement in UserProfileComponent, TypeScript won't immediately complain unless we tell it to. We can enforce this by using the never type in the default case of our switch statement.

function assertNever(value: never): never {
  throw new Error(`Unhandled union member: ${JSON.stringify(value)}`);
}

function UserProfileComponent({ state }: { state: UserState }) {
  switch (state.status) {
    // ... handling idle, loading, error, success ...
    default:
      // If a new status is added to UserState but not handled in this switch,
      // TypeScript will throw a compile error here.
      return assertNever(state);
  }
}

By adding this simple assertNever function, we turn a potential runtime bug (a missing UI state) into a strict compile-time error. If you add a new state, the compiler will instantly show you exactly which switch statements need updating across your entire codebase.

Conclusion

Boolean flags like isLoading and isError are easy to reach for, but they don't accurately model the real world. They create a matrix of potential states, many of which are invalid, leading to fragile UI logic.

By switching to TypeScript Discriminated Unions, we align our code with the actual business logic. We eliminate impossible states, simplify our rendering logic, and get much better support from the TypeScript compiler. It’s a small shift in how you define your types, but it has a massive impact on the maintainability of your application. Try refactoring just one of your complex components to use this pattern, and you'll see the difference immediately.

NT

written by

Nguyên Tech

0

Responses

Loading comments…