DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Stop Modeling State With Booleans: Make Impossible States Impossible in TypeScript

Here is a type you have probably written:

type UserState = {
  isLoading: boolean;
  error?: string;
  data?: User;
};
Enter fullscreen mode Exit fullscreen mode

It looks reasonable. Then one day your UI shows a spinner on top of an error message, and a user stares at a screen that says both "Loading..." and "Something went wrong".

The bug is not in your rendering code. It is in the type, which allows states that should never exist.

Count the States You Actually Allow

That type has three fields. How many combinations can it represent?

  • isLoading can be true or false: 2
  • error can be a string or missing: 2
  • data can be a user or missing: 2

That is 8 combinations, and your app has 4 real states: not started, loading, failed, succeeded. Four of the eight are nonsense:

{ isLoading: true,  error: 'Failed', data: user }   // loading AND failed AND loaded?
{ isLoading: false, error: undefined, data: undefined } // finished, but with nothing?
Enter fullscreen mode Exit fullscreen mode

Every one of those is a bug waiting for a code path that forgets to reset a flag. Your components have to guess which flag wins, and different components guess differently.

The Fix: One Field That Decides Everything

A discriminated union is a type where one shared field, the discriminant, tells you which shape you have.

type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: User };
Enter fullscreen mode Exit fullscreen mode

Now there are exactly four states, and each carries only the data that makes sense for it. A loading state has no data field to forget. A success state always has data, with no ? and no undefined to check.

TypeScript Narrows It for You

function UserCard({ state }: { state: UserState }) {
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Spinner />;
    case 'error':
      return <p>{state.error}</p>;       // error exists here
    case 'success':
      return <h2>{state.data.name}</h2>; // data exists here
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside each case, TypeScript knows exactly which variant you are in. Write state.data inside the error branch and you get a compile error, not a runtime crash.

Using It in React

const [state, setState] = useState<UserState>({ status: 'idle' });

async function load(id: string) {
  setState({ status: 'loading' });
  try {
    const user = await fetchUser(id);
    setState({ status: 'success', data: user });
  } catch (e) {
    setState({
      status: 'error',
      error: e instanceof Error ? e.message : 'Unknown error',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what you no longer do: you never reset error before a retry, and you never clear data on failure. Each setState replaces the whole object, so stale fields cannot survive. That is the entire class of bug that booleans create, gone.

Make the Compiler Tell You When You Forgot a Case

Add a new state later, say 'refreshing', and every switch that does not handle it should complain. You can force that with a never check:

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

switch (state.status) {
  case 'idle':
    return null;
  case 'loading':
    return <Spinner />;
  case 'error':
    return <p>{state.error}</p>;
  case 'success':
    return <h2>{state.data.name}</h2>;
  default:
    return assertNever(state);
}
Enter fullscreen mode Exit fullscreen mode

If a case is missing, state is no longer never in default, and TypeScript reports an error at that exact line. Adding a state turns into a to-do list generated by the compiler.

The Same Trick for API Responses

Functions that can fail are another place where optional fields hide bugs:

// Allows { data: undefined, error: undefined } and { data: x, error: y }
type Result<T> = { data?: T; error?: string };
Enter fullscreen mode Exit fullscreen mode

Replace it with a union:

type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

const result = await createOrder(input);

if (!result.ok) {
  showToast(result.error); // error is a string here
  return;
}

redirectTo(`/orders/${result.data.id}`); // data is defined here
Enter fullscreen mode Exit fullscreen mode

The caller cannot touch data without first checking ok. You cannot forget the error path, because the types will not let you reach the success value until you have handled it. This pattern works nicely for Server Actions and API handlers.

Two Gotchas

1. Use a literal for the discriminant. status: string will not work. The field must be a literal type like 'loading' or true, so TypeScript can tell the variants apart.

2. Data from outside still needs checking. A union describes what your code produces. If you receive JSON from a network, TypeScript cannot know it matches. Validate it with a schema library at the boundary, then trust the type inside.

When Not to Bother

Not everything needs a union. A form field with an optional middle name is fine as middleName?: string. Use unions when several fields depend on each other, meaning some combinations are valid and others are not. If you catch yourself writing a comment like "error is only set when status is failed", that comment belongs in the type.

The Rule

If a combination of fields should never happen, make it impossible to write down. Replace booleans and optional fields with one discriminant, let TypeScript narrow it, and add a never check so new states cannot be forgotten.

Quick audit: search your project for isLoading, isError and ?: in state types. Wherever two or more of them appear together, ask which combinations are real. The gap between the number you allow and the number you need is a list of bugs you have not met yet.

If you work with TypeScript and Next.js, you can browse my templates on pixelanas.com for a typed starting point.

Get the templates: https://pixelanas.gumroad.com

What is the strangest impossible state you have ever seen on a screen? Tell me in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)