TypeScript provides a better alternative. I'd like to show you how I've modeled different logical outcomes using union types.
Example: Fetching data
One of the most common examples of multi-boolean logic is fetching remote data. It's ideal to show e.g. a spinner while data is on it's way (if it might take a while).
In the example below you'll find a combination of an asynchronous operation in
main and intermediate loading state in
render. I wrote the example in a way that is typical for many front-end codebases. Take a moment to consider the logic. We'll improve on this example afterwards.
Introducing union types
You might have noticed that we are dealing with many possible combinations of booleans and data that might be absent (
null). Even with as few as two variables,
data, we have four possible combinations. Rendering logic is definitely missing some of the possible combinations. Logical outcome for output is to render
- Nothing (implicitly - there's no
- A loading message
- User data (once it's present)
- An error message
Ignoring the first option for now, let's model these possible outcomes in a union type.
Now we have solid names for different outcomes. They should explain what we want to model. We also have extra information associated with
Failure types. That will be useful later.
Notice that we have a
type attribute in all the type definitions.
Data is a tagged union, also known as a discriminated union. We need some kind of way to tell different options apart - a tag or a discriminator. It is possible in TypeScript by using literal string values as types. You might have seen this pattern already if you have written actions in
How does this help? See another example below:
Nice, right? Creating
Data values is not very ergonomic, but look at
render! It's super clean compared to the first example.
render now always returns something meaningful. TypeScript knows exactly what type we are dealing with once we have determined the current value of
Data using a
switch statement. That's why we don't have to test for the presence of the data anymore. TypeScript can ensure that all possible variations are taken into account. You only have to enable the
noFallthroughCasesInSwitch option in
But wait! There's still room for improvement...
One of the nicest abstractions I've come across is
RemoteData. I encountered it while working on a semi-large Elm project. Our
Data type is already pretty close to how
RemoteData is defined (hats off to Kris Jenkins!).
Data is often not loading upon application initialization.
RemoteData includes a
NotAsked variant for that case. If data is fetched immediately, one can start from
Loading state, like we did before. I've included a TypeScript version of the library in recent work projects. It's been well-received. Here's
RemoteData in TypeScript:
The type is defined using generics. Folks fetch different shapes of data and things can go wrong in many ways. Let's use
RemoteData in the example.
It's not very different to what we had before, but now all realistic outcomes are modeled. You might have also noticed that I've included some values and helper functions that make it easy to create
Sometimes you still need to test which variant of
RemoteData is present. E.g. you might only care if a value is
Success, and for that you can use helpers below. Using
switch is not mandatory!
Live example with React
So far the
render function has only served as an example and is never called. Here's a live example (with React) that actually renders something.
Advanced example: Initializing an application
Single page applications often need some data to be available for all (sub)components. For example, many logical parts of an application might need to be translated. We have the user data. What if we fetched translations, too?
Let's find out if we can design a solid application initialization logic. Consider the example below:
We are using
RemoteData with two requests. You should take a moment to figure out all the logical outcomes for these concurrent requests. You might want to write the rendering logic yourself as an exercise. In any case, you'll find a naive solution below.
I'm back to using boolean logic for selecting what to render. At least the helper functions are improving readability. Maybe your solution is better?
Three of the many possible outcomes are interesting. Either we are still loading something, have received all the data, or something went wrong. Not that different from previous examples, huh?
Modeling app state
Here's a model for app state in the fashion of
Some of the types include data, like in
RemoteData. It's quite handy - a reference to
Ready will always contain user data and translations. You can share that reference application-wide.
Time to improve on the previous example.
main function updates app state when necessary. There is still the need for a boolean check to see if we have all the required data after one piece of data is loaded. Translations might arrive before user data, or vice versa. Other logic remains the same.
render is (once again) cleaner and all app state are taken into account.
"Your app initialization example is contrived!"
It would be simpler to use
main. The application could wait until all the data is loaded, or for the first failure. Nothing would change for the worse, and there would be less code to maintain. Sure.
I chose the example for a reason. I wanted you to see that something as tricky as race conditions between simultaneous jobs can be managed with proper planning. You might not always have this level of control over when an asynchronous job starts!
I was recently tasked with having key search results show before some of the other results. It was because loading key results took one tenth of the time vs. other results. The pattern used above works in a case like that. Something like
KeyResultsReady is a valid option for
SearchState, just as
AllResultsReady is. Possible flows (barring failures) are then
Again, this makes intentions clear.
Key benefits of using union types for modeling state are
- having to think about all logical possibilities
- naming the states (exhibits intention, works as documentation)
- absent values for data are not required anymore
- cleaner code when using data from the state
There are some tradeoffs. One has to write all the interfaces and helper functions. It takes some time to figure out decent names for different values. There's some learning curve involved.
I would say, though, that this kind of premeditation is useful. The general benefit of adding types is to avoid bugs and to verify that your code does what you think it does. This is why you probably are using TypeScript anyway!
Thanks for reading.