DEV Community

loading...
Cover image for Matching your way to consistent states
HousingAnywhere

Matching your way to consistent states

gillchristian profile image Christian Gill ・8 min read

In the frontend team at HousingAnywhere, we've been writing our React and Node codebases with TypeScript since the end of 2017. We have React powered applications and a few Node services for server-side rendering and dynamic content routing. As our codebase was growing, and more and more code was reused across different pages and modules, we decided we could benefit from a compiler that would guide us. Since then, we've been migrating one file or module at a time. Most of our new code is written in TypeScript, and most of the codebase has been migrated as well.

Since then, we've been looking for ways to leverage the type system to help us make sure our code is correct.

One of the things we realized is that we aren't writing JavaScript anymore. TypeScript is a different language. Yes, most of the semantics of the language are the same, as is the syntax (excluding the types). This is the goal, after all, to build a superset of JavaScript. But it also has a compiler that can guide and helps us write correct code (or at least attempt to do so), which means we can now model many of our problems using types.

This realization wasn't an “Aha” moment, but more of a series of changes we went through and patterns we adopted that enabled us to benefit more from the type system. I want to share one of those patterns with you in this blog.

Time for Modeling States

When coding, it’s very often the case that we have to handle several variants as part of the control flow of our applications. Sometimes it's the status of a request, the state of a component and what needs to be rendered, or the items that need to be displayed from which a user may choose.

In this article, I’ll explain how you can model the status of a request and decide what outcome you want to show to the user as a result. The patterns discussed here can be applied to any other case where only one boolean isn't enough. I’ll walk you through different ways to approach this — from the more traditional imperative style, to a more declarative one that leverages the type system which makes it safer.

In our status, we have four potential scenarios, which we call LoadingStatus: an initial one, before any request is made. There’s also a loading one, while we are waiting for the request to be fulfilled. And lastly, there are success or error cases.

One approach we can take is to use a combination of boolean properties to distinguish between them. This is a very common approach to modeling such statuses and is often the default approach people use. Our first version of LoadingState looks like this:

interface LoadingState<T> {
  isLoading: boolean;
  isLoaded: boolean;
  error?: string;
  data?: T;
}
Enter fullscreen mode Exit fullscreen mode

As I mentioned before, there are several valid combinations possible.

const notAsked = {
  isLoading: false,
  isLoaded: false,
  error: undefined,
  data: undefined,
};
Enter fullscreen mode Exit fullscreen mode
Before any request is made.
const loading = {
  isLoading: true,
  isLoaded: false,
  error: undefined,
  data: undefined,
};
Enter fullscreen mode Exit fullscreen mode
While the request is pending.
const success = {
  isLoading: false,
  isLoaded: true,
  error: undefined,
  data: someData,
};
Enter fullscreen mode Exit fullscreen mode
A successful resolution 🎉
const failure = {
  isLoading: false,
  isLoaded: true,
  error: "Fetch error",
  data: undefined,
};
Enter fullscreen mode Exit fullscreen mode
A failed resolution 😞

We can use that loading state in a React component to show the status and data to the user.

const MyComponent = ({loadingState}) => {
  if (loadingState.isLoading) {
    return <Spinner />;
  }

  if (loadingState.error) {
    return <Alert type="danger">There was an error</Alert>;
  }

  if (loadingState.isLoaded) {
    return <Content data={loadingState.data} />;
  }

  return <EmptyContent />;
};
Enter fullscreen mode Exit fullscreen mode

This works well for all the valid states. Now, what if our state looks like this? What should be displayed to the user?

const what = {
  isLoading: true,
  isLoaded: true,
  error: "Fetch error",
  data: someData,
};
Enter fullscreen mode Exit fullscreen mode

Something clearly went wrong for us to get into this state. An option could be to accept that "this is how it is: data can be inconsistent" and write tests to prevent our code from getting to this inconsistent state. Another approach would be to assume that an inconsistent state is another kind of error and write code that checks the state for inconsistencies, and show an error to the user when this happens.

But wait a minute. Since our code is now statically typed, is there something that could be done to solve this problem?

I’ll go through a series of improvements showing the potential and more generic ones for you to consider, and end with the version I would actually recommend using. Disclaimer: these patterns are not very common and could require some effort to get your team on board. That said, I believe you’ll find that they’ll be worth the extra effort (in my opinion, it’s not that much extra effort, anyway).

Here we go:

Matching cases: One property to decide them all

For the first, case we’ll start with the most generic pattern. Since all the cases are mutually exclusive (e.g. there shouldn’t be data and an error at the same time), one step in the right direction would be to define all the cases as a union type.

type Status =
  | "not_asked"
  | "loading"
  | "success"
  | "failure";

interface LoadingState<T> {
  status: Status;
  data?: T;
  error?: string;
}
Enter fullscreen mode Exit fullscreen mode

Now that we have one value as the source of truth of our status, we can update our component.

const MyComponent = ({loadingState}) => {
  if (loadingState.status === "not_asked") {
    return <EmptyContent />;
  }

  if (loadingState.status === "loading") {
    return <Spinner />;
  }

  if (loadingState.status === "failure") {
    return (
      <Alert type="danger">
        {loadingState.error || "There was an error"}
      </Alert>
    );
  }

  if (laodingState.status === "success") {
    return loadingState.data ? (
      <Content data={loadingState.data} />
    ) : null;
  }
};
Enter fullscreen mode Exit fullscreen mode

It’s much better already. We (almost) solved the inconsistency problem, as our status is defined by only one value now. Before we get into why I say we almost solved the problem, let’s tidy things up a bit. One of the goals I mentioned at the beginning was to make our code more declarative. However, if statements are by definition imperative. If we take a step back and consider what we’re doing, we’re trying to match each variant in a way so it can handle that specific case. This can be translated into a very short and simple, yet powerful utility function.

type Matcher<Keys extends string, R> = {
  [K in Keys]: (k: K) => R;
};

const match = <Keys extends string, R = void>(
  m: Matcher<Keys, R>,
) => (k: Keys) => m[k](k);
Enter fullscreen mode Exit fullscreen mode

If we remove the types, we’re left with a function that takes an object and returns a function that takes a string, and uses that string to lookup a property of the object and call it with the string. Yeah, it’s even longer in English than it is in code.

const match = (m) => (k) => m[t](k);

match({foo: () => "bar"})("foo"); // => 'bar'
Enter fullscreen mode Exit fullscreen mode

The implementation is simple and doesn’t say much, so let’s take a look at the types. We’re providing the keys of the object as a type parameter. It has to extend the string, meaning it can be an enum or a union type of strings. This guarantees that the object must have all the keys defined, providing an exhaustiveness check (i.e. the compiler gives an error if one of the properties is missing).

Now, we can update our code once more.

const MyComponent = ({loadingState}) => (
  match < Status,
  React.ReactNode >
    {
      not_asked: () => <EmptyContent />,
      loading: () => <Spinner />,
      success: () =>
        loadingState.data ? (
          <Content data={loadingState.data} />
        ) : null,
      failure: () => (
        <Alert type="danger">
          {loadingState.error || "There was an error"}
        </Alert>
      ),
    }(loadingState.status)
);
Enter fullscreen mode Exit fullscreen mode

This makes things more declarative. Much better! The compiler will remind us if we forget to handle one of the cases, which is very convenient.

compiler error on missing property

Besides making our code more declarative by expressing what each case should return instead of explicitly defining how each case should be determined, we’re also making the code easier to update in the future. As we add more cases to your union type, we don't have to search all over the place for code that needs updating. The compiler will simply inform us, and we can be sure that every case is covered.

At HousingAnywhere, we like this pattern a lot and not only use it for React components, but basically everywhere in our code (reducers, thunks, services, etc). Even though it is a simple and short module, we’ve made it available as a package: @housinganywhere/match.

Fine Tuning: One last match

We’ve already made big improvements compared to the initial approach, and it scales very well thanks to the exhaustiveness check.

But as you might have seen in the previous examples, we still have to check the success and failure cases for undefined values. Why do we want to avoid this? Because we still have the chance to end up with inconsistent states that make no sense.

const leWhat = {
  status: "not_asked",
  data: someData,
  error: "Oops this makes no sense!",
};

const queEsEsto = {
  status: "success",
  data: undefined,
  error: undefined,
};
Enter fullscreen mode Exit fullscreen mode

What we really want is for the compiler to guarantee us that we can only have consistent states. As mentioned in the beginning, when we consume the LoadingState, we’re basically matching each of the cases to handle them. We should, somehow, not only match the status but instead the whole shape of our state.

Enter discriminated unions (also known as tagged unions or algebraic data types).

A discriminated union consist of a union of all of the cases, where each case should have a common, singleton type property, the discriminant or tag. By checking that discriminant in our code with type guards, the compiler is able to understand which case we are matching.

Let’s define our LoadingState once more, as a discriminated union this time.

type LoadingState<T> =
  | {status: "not_asked"}
  | {status: "loading"}
  | {status: "success"; data: T}
  | {status: "failure"; error: string};
Enter fullscreen mode Exit fullscreen mode

Now we can implement a version of match that is tailored specifically for this version of LoadingState.

type LoadingStateMatcher<Data, R> = {
  not_asked: () => R;
  loading: () => R;
  success: (data: Data) => R;
  failure: (err: string) => R;
};

const match = <Data, R = void>(
  m: LoadingStateMatcher<Data, R>,
) => (ls: LoadingState<Data>) => {
  if (ls.status === "not_asked") {
    return m.not_asked();
  }

  if (ls.status === "loading") {
    return m.loading();
  }

  if (ls.status === "success") {
    return m.success(ls.data);
  }

  return m.failure(ls.error);
};
Enter fullscreen mode Exit fullscreen mode

Again, we define a matcher object with methods for each of the cases of the status. But this time, each method has a different signature and will be called with the data or error when it should. Checking the discriminant (status property) allows the compiler to understand the case we are in.

refined cases

The compiler knows data is defined when status is 'success

We will update the usage one more time with the final version of our match utility.

const MyComponent = ({loadingState}) =>
  match<SomeData, React.ReactNode>({
    not_asked: () => <EmptyContent />,
    loading: () => <Spinner />,
    success: (data) => <Content data={data} />,
    failure: (err) => <Alert type="danger">{err}</Alert>,
  })(loadingState.status);
Enter fullscreen mode Exit fullscreen mode

Not only have we made our handling of the status more declarative and concise, but it’s also much safer, as we can’t get into those weird inconsistent states without the compiler screaming gently telling us we did something wrong.

Although these benefits may not seem like a big deal, they are quite significant. The whole point of a type system is not to have yet another source of errors to look at when we make a mistake in our code. Instead, its function is to help us write safer code, by modeling the code in such a way that we can identify that it’s correct at compile time.

Further reading

If you want to see this pattern to model other problems, take a look at these two examples:

The ideas I present here aren’t new or exclusive to TypeScript. To learn more about them, you can take a look at how pattern matching (i.e. matching discriminated unions and data shapes) is implemented natively in other languages, and how type-driven development can be leveraged to write code that cannot be in inconsistent states.

Happy and safe coding! 🎉

Discussion (8)

Collapse
exadra37 profile image
Paulo Renato

Handling state transitions, and make sure they don't end up in an invalid state seems a good fit for the Finite State Machine concept. Did you ever explored it?

Collapse
gillchristian profile image
Christian Gill Author • Edited

Yeah, as you say, when you need to make sure transitions are valid state machines are the way to go. I did explore them a bit but never went deep into the topic.

Do you have any good resources for state machines in TypeScript?

Collapse
exadra37 profile image
Paulo Renato

Unfortunately no.

I cam across them several times but never cared about to dive on the matter, until I read this post in the Elixir forum, where the author links to 2 videos that helped him to create his Finite State Machine library for Elixir.

Despite being the Elixir community, feel free to ask if they know something specific for TypeScript, because they are very open to discuss other languages, and they are a very nice bunch o people.

Collapse
macsikora profile image
Maciej Sikora

Very nice article. Exhaustiveness checking is the thing!

Collapse
gillchristian profile image
Christian Gill Author

Thanks!

Yeah, I was quite surprised when I found out that it was as simple as making that Matcher object require all the elements of the unions as keys. TypeScript 🤘

Collapse
marcinkoziej profile image
Marcin Kozey

Thanks for the article. Could You explain what is the benefit of using match() function in comparison to using TypeScript switch statement? AFAIK it also performs the exhaustiveness check

Collapse
gillchristian profile image
Christian Gill Author • Edited

To me match feels more declarative, we aren't checking the internal representation of the RemoteData value, only providing handlers for all the cases. And since it is function call, it is an expression, wheres switch is a statement.

Eg. going from this to using switch would require quite some boilerplate code.

const MyComponent = ({ data }) => (
  <div className="my-component-styles">
    {match({ ... })(data)}
  </div>
)
Enter fullscreen mode Exit fullscreen mode

It's true that TypeScript can do exhaustiveness check on the switch statement. But that requires either to add a default case with a never assertion, or to be in a context where we have to return something in all the branches.

Eg. in a case like this, the switch would produce no error but the match would.

switch(someRemoteData.status) {
  case 'not_asked':
    doSomethingWhenNotAsked()
    break
  case 'loading':
    doSomethingWhenLoading()
    break
}

match({
  not_asked: () => doSomethingWhenNotAsked(),
  loading: () => doSomethingWhenLoading(),
})(someRemoteData)
Enter fullscreen mode Exit fullscreen mode

Ultimately, I think it's a matter of personal preference, based on what are the priorities and what the team feels more comfortable with.

Forem Open with the Forem app