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;
}
As I mentioned before, there are several valid combinations possible.
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 />;
};
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,
};
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;
}
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;
}
};
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);
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'
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)
);
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.
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,
};
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};
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);
};
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.
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);
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.
- Reasonml: Pattern matching
- Making impossible state impossible
- How Elm Slays a UI Antipattern
- Remote Data implementation in TypeScript
Happy and safe coding! 🎉
Top comments (7)
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?
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?
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.
Very nice article. Exhaustiveness checking is the thing!
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 🤘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
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, wheresswitch
is a statement.Eg. going from this to using
switch
would require quite some boilerplate code.It's true that TypeScript can do exhaustiveness check on the
switch
statement. But that requires either to add adefault
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 thematch
would.Ultimately, I think it's a matter of personal preference, based on what are the priorities and what the team feels more comfortable with.