Originally published at programming-elm.com.
In the last post, you learned how boolean return values cause boolean blindness. Boolean blindness can create bugs in conditional statements by letting code access data that shouldn't be available. We swapped out boolean return values with Maybe
and a custom type to wrap the data in a particular type constructor and provide more meaningful return values. This let the compiler ensure code only accessed data when it was truly available.
In this post, you will see that boolean properties in Elm records open the door to invalid state configurations. Boolean properties require more complex conditional code and more tests to prevent bugs. You will learn that custom types—seeing a pattern here—eliminate the need for complex code and tests by harnessing the power of the compiler to prevent invalid state configurations.
The Problem
I begin my talk Solving the Boolean Identity Crisis with a problem I encountered while building applications with Redux and React. When fetching data from a server, I would track the state of fetching that data with multiple boolean properties. Unfortunately, I brought that pattern over to the Elm applications I built.
For example, let's say we're building a application for tracking rescue dogs. We need to fetch a dog from the server. Initially, we wouldn't have a dog, so we would likely have a model like this.
type alias Model =
{ dog : Maybe Dog }
That seems reasonable so far. Next, we want to display a loading spinner while we fetch the dog from a server. So, we could add a fetching
property to the model.
type alias Model =
{ dog : Maybe Dog
, fetching : Bool
}
When fetching
is True
, we will display the spinner. When fetching
is False
, we will display nothing.
Once we have the dog, fetching should be False
, but we want to display the dog. We could add a success
boolean property to indicate we have the dog.
type alias Model =
{ dog : Maybe Dog
, fetching : Bool
, success : Bool
}
Now, if success is True
, we display the dog. Otherwise, if it's False
and fetching is False
, then we're back in a "ready to fetch" state and should display nothing. (Alternatively, we could look at a combination of fetching
and if dog
is Just
or Nothing
to decide what state we're in.)
All seems well, but the dog could not exist on the server or we could encounter other server errors. We need to know if the request failed and handle any errors appropriately. Well, we could add an error
boolean property along with an errorMessage
property.
type alias Model =
{ dog : Maybe Dog
, fetching : Bool
, success : Bool
, error : Bool
, errorMessage : String
}
If error is True
, then we can display the errorMessage
. Otherwise, we'll need to examine the other boolean properties to determine what to do.
If we were to handle this in the view
function, it might look like this.
view : Model -> Html Msg
view model =
if model.error then
viewError model.errorMessage
else if model.fetching then
viewSpinner
else if model.success then
viewDog model.dog
else
viewSearchForm
The view
function has a couple of issues.
- It suffers from boolean blindness. We depend on certain boolean properties to be true before attempting to access data. Nothing stops us from accessing data in other branches such as
model.dog
ormodel.errorMessage
. (Granted, if we tried to accessmodel.dog
, we'd still have the safety ofMaybe
.) - It requires more thorough automated testing to ensure we handle all cases properly. We could leave out all the
else if
branches and the code would still compile even if it was incorrect.
Also, our model can arrive at incorrect configurations like this.
{ dog = Just { name = "Tucker" }
, fetching = True
, success = True
, error = True
, errorMessage = "Uh oh!"
}
All boolean properties are true, we have a dog, and we have an errorMessage
. We're hard-pressed to determine what state we're really in. We have no choice but to depend on the arbitrary ordering of the if-else conditionals in view
to make that decision. Of course, we'll need a strong test suite to ensure we can't configure the model like this.
Prevent Invalid State
I finally realized the problem with how I represented my data. I thought the states of fetching data (ready, fetching, success, and error) were separate from one another. Really, they are different state values of the same overall state. That sounds like a state machine.
A state machine can only be in one state value at a time. My record representation forbid that by letting multiple state values be True
. Elm has an awesome type system and compiler. We should leverage them as much as possible to prevent invalid state configurations by essentially creating a state machine.
We could introduce a new custom type.
type RemoteDoggo
= Ready
| Fetching
| Success Dog
| Error String
The RemoteDoggo
type has four constructors that map to each possible state, Ready
, Fetching
, Success
, and Error
. We wrap a Dog
with the Success
constructor and wrap a String
error message with the Error
constructor. Then, we can update the model to look like this.
type alias Model =
{ dog : RemoteDoggo }
We remove all but the dog
property and change the dog
property to the RemoteDoggo
type. We can now transform the view
function into this.
view model =
case model.dog of
Ready ->
viewSearchForm
Fetching ->
viewSpinner
Success dog ->
viewDog dog
Error error ->
viewError error
Instead of worrying about the order of boolean properties, we pattern match on the dog
property with a case
expression. We map each constructor to the appropriate view helper function.
Our code has gained a few benefits here by this change.
- The code makes the states explicit with the
RemoteDoggo
type. - We eliminated boolean blindness. We can only access the dog in
Success
and the error message inError
. - We have compiler enforced UI states. If we forget to handle one of the
RemoteDoggo
values, then our code won't compile.
This code:
view model =
case model.dog of
Ready ->
viewSearchForm
Fetching ->
viewSpinner
Success dog ->
viewDog dog
-- forgetting to handle errors
Will produce this compiler error:
This `case` does not have branches for all possibilities:
|> case model.dog of
|> Ready ->
|> viewSearchForm
|>
|> Fetching ->
|> viewSpinner
|>
|> Success dog ->
|> viewDog dog
Missing possibilities include:
Error _
Now our code is clearer and safer thanks to custom types.
What You Learned
In this post, you learned that boolean properties can cause invalid state configurations, which create bugs that the compiler can't catch. Boolean properties lead to complex if-else expressions with arbitrary ordering that are hard to follow. You saw that by reducing the boolean properties down to one property with a custom type you can write more explicit code with compiler safety. If you have some code with multiple boolean properties like this, try refactoring to a custom type to make your code clearer and safer.
Further Resources
For more info on how to use Elm's type system to prevent invalid state configurations, watch Richard Feldman's talk Making Impossible States Impossible.
For a more general type similar to RemoteDoggo
, check out the krisajenkins/remotedata package.
To learn more about how to build Elm applications effectively, grab a copy of my book Programming Elm from The Pragmatic Programmers.
Top comments (0)