react
typescript
patterns
techniques
clean-code
code-quality

Exhaustiveness Checking And Discriminant Property: The Complete Guide

In today's world, we're constantly fetching data, saving it, and handling asynchronous operations. That's why it's essential to have a clean, well-defined structural representation. A concise model ensures smooth management of your application's behavior, especially when dealing with complex interactions. A well-defined model is healthy for managing state, showing the result of operations, and defining principles for the behavior of your code.

Thus, let’s consider a typical example of managing the result of an API call:

try {
  // Resetting state
  setResult({ pending: true, error: null, profile: null }); 
  // Loading profile data
  const profile = await getUserProfile(); 
  // Success handling
  setResult({ pending: false, profile, error: null }); 
} catch (error: unknown) {
  // Error handling
  setResult({ pending: false, error, profile: null }); 
}

Here’s the thing - this pattern hides several potential pitfalls! While flags are necessary in some cases, relying on them for every single request can quickly become overwhelming and impractical. Why is that? Let’s dive into the details in this article and improve your modeling skills!

The Problems To Solve

Let’s define the shape of our async operation - fetching a user profile and displaying it - and write the logic to make this feature fully functional.

Imports have been omitted for brevity.

type User = {
  id: number;
  name: string;
};

type UserProfileResult = {
  loading: boolean;
  error: Error | null;
  profile: User | null;
};

const UsersContainer = () => {
  const [result, setResult] = React.useState<UserProfileResult>({
    loading: false,
    error: null,
    profile: null,
  });

  React.useEffect(() => {
    const loadUserProfile = async (): Promise<void> => {
      try {
        setResult({ pending: true, error: null, profile: null });
        const profile = await getYourUserProfile();
        setResult({ pending: false, profile, error: null }); 
      } catch (error: unknown) {
        setResult({ pending: false, error, profile: null }); 
      }
    };

    loadUserProfile();
  }, []);

  if (result.loading) return <Spinner />;
  if (result.error) return <GenericErrorMessage />;
  if (!result.profile) return <NoProfileMessage />;
  return <UserProfile profile={result.profile} />;
};

This works, but it introduces several issues. Let’s break them down.

Hard Model To Work With

Imagine you're trying to quickly check if the data has loaded successfully. With the current approach, you'd end up writing awkward, clunky conditional statements like this:

if (!loading && !error && data)

Need to check if data is still loading but without errors? That’s another tricky condition:

if (loading && !error)

Now consider a situation where the profile has been loaded but is null because the user hasn’t added any data yet. This adds another layer of complexity:

if (!loading && !error && !!user)

These checks pile up and create repetitive, convoluted logic scattered across your codebase. It’s also easy to slip up. For example:

// A simple mistake - setting `pending` to `false` instead of `true`.
setResult({ pending: false, error: null, profile: null });

Conditional Logic Issues

Conditional rendering might seem straightforward when you’re dealing with a single block. But as more conditions pile up, things can get messy:

return (
  <>
    {busy && <Spinner />}
    {error && <GenericError />}
    {profile ? <UserProfile profile={profile} /> : <NoProfileMessage />}
  </>
);

At first glance, this looks fine. But here’s the catch: it will show the <Spinner> alongside the user profile message. If busy is true and profile is null, you’ll end up displaying both, leading to a confusing UI.

To fix this, you could add more checks. But as you can see, the result is far from elegant:

return (
  <>
    {busy && <Spinner />}
    {error && <GenericError />}
    {!busy && !!error && (
      <>{profile ? <UserProfile profile={profile} /> : <NoProfileMessage />}</>
    )}
  </>
);

Hard To Read

Nested conditional logic can quickly become overwhelming. Take this example:

return (
  <>
    {busy && <Spinner />}
    {error && !busy && !profile && <GenericError />}
    {!busy && !error && profile && (
      <>
        {profile.isAdmin ? (
          <AdminProfile profile={profile} />
        ) : (
          <UserProfile profile={profile} />
        )}
        {profile.isNewUser && (
          <WelcomeMessage userName={profile.name} />
        )}
      </>
    )}
    {!busy && !error && !profile && <NoProfileMessage />}
  </>
);

It can be mentally exhausting to keep track of all the scenarios. The deeper the nesting, the more difficult it becomes to see the overall flow of the code.

Hard To Maintain And Refactor

A small mistake in negation can silently break the entire feature:

// From
{error && !busy && !profile && <GenericError />}
// To
{!error && !busy && !profile && <GenericError />}

Without proper tests, these kinds of bugs can slip through unnoticed. Now, the error won’t display, even though it actually occurred.

Complexity

Managing all combinations of states adds unnecessary complexity:

StatebusyerrorprofileExpected Result
Loading Spinnertruefalsenull<Spinner />
Error, No Profilefalsetruenull<GenericError />
No Error, Profilefalsefalse{...}<UserProfile />
No Error, No Profilefalsefalsenull<NoProfileMessage />

Problem With Tests

The same complexity and repetition appear in tests:

test("renders generic error when there is an error", () => {
  render(<YourComponent busy={false} error="Error" profile={null} />);
  expect(screen.getByText("GenericError")).toBeInTheDocument();
});

Poor IDE Developer Experience

TypeScript and your IDE won't catch these errors. Without tests, misconfigured flags lead to silent failures.

TypeScript fails to warn

Exhaustiveness Checking Explained

The exhaustiveness checking technique ensures that all possible cases of a union type in TypeScript are explicitly managed. It triggers an error in the IDE (thanks to TypeScript) if any case is missing.

type Status = "loading" | "success" | "error";

const handleStatus = (status: Status): string => {
  switch (status) {
    case "loading":
      return "Loading...";
    case "success":
      return "Success!";
    case "error":
      return "An error occurred.";
    default:
      // TypeScript will enforce completeness by throwing 
      // an error if a case is unhandled
      const exhaustiveCheck: never = status;
      return exhaustiveCheck; // Guarantees all cases are covered
  }
};

This ensures TypeScript raises an error directly in the IDE, promoting code safety and completeness. Here’s how it behaves in the IDE:

Exhaustiveness Checking Technique

So, thanks to this, we’ll be warned every time we forget to handle any case described in the union type. Feels fancy, right ☜(゚ヮ゚☜)?

Discriminant Property Explained

A discriminant property is a shared, literal-typed property used in a union of object types to differentiate between the types (their shapes). This allows TypeScript to narrow down the specific type being handled based on the value of the property.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; length: number; width: number };

const getArea = (shape: Shape): number => {
  switch (shape.kind) {
    case `circle`:
      return Math.PI * shape.radius ** 2;
    case `square`:
      return shape.side ** 2;
    case `rectangle`:
      return shape.length * shape.width;
    default:
      const exhaustiveCheck: never = shape;
      return exhaustiveCheck;
  }
};

Here is how it behaves in the IDE:

Discriminant Property In IDE

I hope you noticed that it "highlighted" only properties that are included in a certain object type. It's really powerful because it will guide us by hand when handling different types of shapes - in our case, the state shapes we’ll tackle soon.

So, it's related and reserved for objects, helping us determine which variant we’re currently working with. Seems promising in terms of our case from the beginning? Yes!

Let's Solve The Problems!

Here I'll paste the final result, which you should easily understand based on the previous explanations. I'll explain the critical parts and show how it behaves in the IDE.

type User = {
  id: number;
  name: string;
};

type UserProfileResult =
  | { is: `idle` }
  | { is: `busy` }
  | { is: `ok`; profile: User }
  | { is: `unset` }
  | { is: `fail`; error: string };

const UsersContainer = () => {
  const [result, setResult] = React.useState<UserProfileResult>({ is: `idle` });

  React.useEffect(() => {
    const loadUserProfile = async (): Promise<void> => {
      try {
        setResult({ is: `busy` });
        const profile = await getYourUserProfile();
        profile ? setResult({ is: `ok`, profile }) : setResult({ is: `unset` });
      } catch (error: unknown) {
        setResult({
          is: `fail`,
          error: error instanceof Error ? error.message : `Ups!`,
        });
      }
    };

    loadUserProfile();
  }, []);

  if (result.is === `idle` || result.is === `busy`) return <Spinner />;
  if (result.is === `ok`) return <UserProfile profile={profile} />;
  if (result.is === `unset`) return <NoProfileMessage />;
  if (result.is === `fail`) return <GenericError />;

  const exhaustiveCheck: never = result;
  return exhaustiveCheck;
};

So, here is the summary table that shows how complexity has been reduced:

StateIsExpected Result
Loading Spinneridle | busy<Spinner />
Error, No Profilefail<GenericError />
No Error, Profileok<UserProfile />
No Profile, No Errorunset<NoProfileMessage />

It's easy to predict this code, and there is plenty of room to remove boilerplate - you can always craft an utility type, like the one described in the Crafting transaction utility type in TypeScript article.

Testing, mocking, and setting the state are now a breeze. You'll get the same hints when writing tests as you do when implementing logic!

test("renders generic error when there is an error", () => {
  render(<YourComponent result={{ is: `fail`, error: `Ups!` }} />);
  expect(screen.getByText("GenericError")).toBeInTheDocument();
});

Last, the code is readable and easy to understand. I don't need to implant a cybernetic core in my brain to make sense of these weird, snaky, spaghetti if statements.

Listing And Dealing With Gotchas

There is nothing perfect, as you probably know. So, here is the list of potential issues/problems/headaches that you will need to deal with.

Presented techniques are great for modeling states in most scenarios, but may be an overkill or cause problems when implemented in the wrong context.

Additional Declarative Code

Using these techniques is really cool, but there is one important gotcha: what if the state is stored globally, and you want to read it in several places? This will require you to perform these checks every time you attempt to read the state.

So, with strictness comes the need to check such state. This makes our code safer, but it also increases boilerplate - as always, tradeoffs.

You can fix this by using custom type guard functions that ensure you're only doing something when the state has the correct shape. If not, it will throw an error. For example, you can't change the user profile when the state is still busy - there's no profile yet!

type User = {
  id: number;
  name: string;
};

type UserProfileResult =
  | { is: `idle` }
  | { is: `busy` }
  | { is: `ok`; profile: User }
  | { is: `unset` }
  | { is: `fail`; error: string };

const assertOkUserProfileResult = (
  result: UserProfileResult,
): Extract<UserProfileResult, { is: `ok` }> => {
  if (result.is !== `ok`) throw Error(`Invalid state read attempt`);
  return result;
};

// Throws error
assertOkUserProfileResult({ is: `idle` });
// Allows to perform operations
const { profile } = assertOkUserProfileResult({
  is: `ok`,
  profile: { id: 1, name: `Tom` },
});

// Doing something with the profile is safe here
console.log(profile);

Having such a guard is really useful as it ensures that the state you're reading is the one you're expecting. TypeScript will guide you and protect you from common mistakes during state transitions.

const Parent = () => {
   if (state.is === `ok`) return <UserProfile />;
   // ...other code
}

const UserProfile = () => {
   const state = useUserProfileStore(assertOkUserProfileResult);
   // Here the state has the shape of "ok"
};

By using the ok state variant in the component's body, it is protected from being rendered if state.is is not ok. This removes the need to manually check the state shape again in the UserProfile. We "expect" the state to be ok because this component is only compatible with this state variant.

Thus, without this critical if (state.is === "ok") in the parent component, the rendering attempt will throw an error - we'll immediately know that we're doing something unallowed. How can we render the user profile if it's not loaded and not defined?

Easy To Overuse

Some developers may overuse this approach, creating deeply nested unions that increase complexity, or applying these techniques in very simple cases - like for an if statement - yes, I've seen that before...

So, I use it only in the following cases:

  1. There are at least 2+ different states to model.
  2. There are different structural shapes for each state.
  3. There aren't too many deeply nested unions.
  4. There are distinct variations in behavior, where a single property value can completely alter the outcome.

For everything else (usually), I stick with typical codebase patterns - flags, if statements, null, etc. There may be exceptions, but asking myself these questions helps a lot in deciding what to use.

State Persistence Issues

So, there is a small issue that may occur if you use a persistence mechanism - let's say you want to save some data inside local storage, retrieve it, and set it as initial when the browser is refreshed.

If you're using a generic mechanism, you need to be careful not to load the entire state into storage. Only save the part that you want to persist, to avoid saving the "view" state. It should be something like idle - determine if there is data in local storage, then change to ok.

// @@@ Selective persistence - that's good 💚 @@@
const saveToStorage = (state: UserProfileState) => {
  if (state.status === `ok`) {
    localStorage.setItem(`profile`, JSON.stringify(state.profile));
  }
};

const getFromStorage = (): UserProfileState => {
  const profile = localStorage.getItem(`profile`);

  return profile ? { status: `ok`, profile: JSON.parse(profile) } : { is: `idle` };
};
// @@@ Full persistence - do not do that!!! 💢 @@@
const saveToStorage = (state: UserProfileState) => {
  if (state.status === `ok`) {
    localStorage.setItem(`userProfileState`, JSON.stringify(state));
  }
};

const getFromStorage = (): UserProfileState => {
  const savedState = localStorage.getItem(`userProfileState`);

  return savedState ? JSON.parse(savedState) : { status: `idle` };
};

If you do it the "💢 mark" way, you'll run into problems, like full-screen loaders that stay on forever. That's why it's important to use selective persistence instead of full persistence. The data that needs to persist defines the state, and the state defines the view, so there's no risk of a blocked UI. If something goes wrong, there won't be a saved state showing spinner forever.

Pros And Cons

Here's a full list of pros and cons to give you the complete picture.

Pros

  • Type Safety: Catches missing cases early, preventing unexpected behavior.
  • Early Errors: TypeScript alerts you if something’s missing, saving debugging time.
  • Scalable: Ensures new cases in unions are handled, making code easier to extend.
  • Improves Code Quality: Forces you to consider all edge cases.
  • Readability: Discriminant properties make it clear which case is being handled.
  • Automatic Narrowing: Discriminants narrow types automatically, simplifying code.
  • Enforces Structure: Makes union types more consistent and predictable.

Cons

  • Verbosity: Adding exhaustiveness checks can make code longer, especially for small unions.
  • Extra Work: Might feel like unnecessary overhead for simple types.
  • Confusing: New developers may find never checks and discriminants tricky.
  • Complexity: Can be harder to manage with nested types or unions.
  • Overuse: Relying too much on discriminants can lead to bloated types.
  • Missing Checks: Discriminants can cause runtime issues if not handled correctly.
  • Storage Hydration Issues: As mentioned earlier, when the "loading" state is persisted, it may require additional code to reset and return to a valid state.
  • The Guards Selectors Need: If state is declared in one component, but used in another, and not passed as a prop, it will require the usage of the aforementioned guards.

So, before using these techniques, you need to consider your use case. If you want to be really "strict" and cover all edge cases of your feature, there’s definitely a place for these patterns. However, for simpler scenarios - like when implementing fire-and-forget requests - it might be overkill.

Balance and using the right tool for the problem is key, as always...

Summary

Now you know how to model complex data structures, application state, and API responses, handling them effectively without creating a complex codebase that’s hard to read - even for AI!

However, it’s important to note that if these techniques are used for really simple use cases, they might introduce unnecessary complexity. It’s all about finding the right tool for the job.

Personally, I model every async operation with pending, ok, and failure states. This makes life easier and ensures consistency across the entire codebase.

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'pending' }
  | { status: 'ok'; data: T }
  | { status: 'fail'; error: string };

Additionally, I use this pattern to handle different types of objects stored in a database, determining the logic based on a single property.

type Entity =
  | { type: 'user'; userId: number }
  | { type: 'post'; postId: number }
  | { type: 'comment'; commentId: number };

const handleEntity = (entity: Entity) => {
  switch (entity.type) {
    case 'user':
      return <UserProfileView userId={entity.userId} />;
    case 'post':
      return <PostView postId={entity.postId} />;
    case 'comment':
      return <CommentView commentId={entity.commentId} />;
    // ...other cases
  }
};

This approach works really well when you have a complex UI with multiple different views, and you don’t want to use routing. Each step in the UI can have a separate identifier as a discriminant property, and the state inside can be used to render the appropriate component.

type UIState =
  | { step: 'intro'; data: string }
  | { step: 'details'; data: number }
  | { step: 'review'; data: boolean };

const renderUI = (state: UIState) => {
  switch (state.step) {
    case 'intro':
      return <IntroStep content={state.data} />;
    case 'details':
      return <DetailsStep content={state.data} />;
    case 'review':
      return <ReviewStep content={state.data} />;
    // ...other cases
  }
};

Lastly, for presentational components with different behaviors based on props:

type ButtonProps =
  | { variant: 'primary'; label: string; size?: 'small' | 'large'; }
  | { variant: 'secondary'; label: string; size?: 'small' | 'large'; }
  | { variant: 'danger'; label: string; size?: 'small' | 'large'; }
  | { variant: 'outline'; label: string; size?: 'small' | 'large'; };

const getButtonProps = (props: ButtonProps) => {
  switch (props.variant) {
    case 'primary':
      return { style: { backgroundColor: 'blue', color: 'white' }, className: 'btn-primary' };
    case 'secondary':
      return { style: { backgroundColor: 'gray', color: 'black' }, className: 'btn-secondary' };
    case 'danger':
      return { style: { backgroundColor: 'red', color: 'white' }, className: 'btn-danger' };
    case 'outline':
      return { style: { backgroundColor: 'transparent', color: 'blue', border: '1px solid blue' }, className: 'btn-outline' };
    default:
      return { style: {}, className: '' };
  }
};

const Button = (props: ButtonProps) => {
  const { label, variant, size } = props;
  const { style, className } = getButtonProps(props);

  return (
    <button className={className} style={style}>
      {label} {size && <span>({size})</span>}
    </button>
  );
};

I hope you found this helpful. As with any advanced technique, it’s crucial to be careful and use it only when it provides real value.

To see an overused example of techniques and patterns, you can read the Be careful with design patterns article.

As an addon, it's worth mentioning that these techniques are used in almost every component library, or in more advanced ones like reactflow, to define different components and models for presentation graphs.

Before you leave this long article, just a small statement at the end: there is nothing perfect. The techniques presented make your code stricter, but they do add a bit of boilerplate. I would say it's minimal impact, but for some, it might feel like overhead. The decision is yours - I just showcased the possibilities ☜(゚ヮ゚☜).

Author avatar
About Authorgreenonsoftware

We are a company dedicated to providing mentorship, education, and consultation in web technologies such as React, Cypress, Node, AWS, and more. Visit our website or contact us for more info