How to make impossible states impossible?

Comparing different approaches to cleaning your Elm code

Up and Down the Escher Stairs | by Shawn Clover

Some time ago, Richard Feldman gave an excellent talk on “making impossible states impossible”. It really caught on in the Elm community. The key point was more or less:

If certain combinations of data are not allowed in your app, design you data model in such a way that it can only hold allowed data combinations.

E.g. if an “answer” in your model can only exist if there is a corresponding “question”, then ensure that your data model can only hold an answer if the corresponding question is there too. 
Be sure to check out the video if you haven’t seen it yet.

When applying this wise lesson in my own code, I found that there are different ways to make impossible states impossible. So I tried different ones, to see how a specific data model structure would impact code elsewhere.

This article is not so much about why it is a good thing to make impossible states impossible. It is more of an exercise to find out how to do it. And compare some different ways of doing it.

The example use case

To compare I use an example (perhaps familiar to some), of a set of 2 dropdowns, to select a destination city.

New and improved gif of the dropdown in action
  • When there is no country selected from the dropdown, you cannot select a city; the city dropdown is disabled.
  • The city dropdown can be empty, when there is no city selected yet.
  • When you select a different country, the city dropdown will reset. If you select the same country again, any previously selected city will be preserved.

Version 1: Two Maybes — The naive data model

My original (naive) model for this was:

type alias Model =
{ country : Maybe Country
, city : Maybe City
}

This data model actually allows impossible states, like:

{ country = Nothing
, city = Just "Paris"
}

Technically allowed, but it shouldn’t be possible to have a city selected without a country.

This original data model requires the update function to include checks to prevent or repair these impossible combinations:

update : Msg -> Model -> Model
update msg model =
case msg of
CountryPicked pickedCountry ->
let
newCity =
if model.country /= Just pickedCountry then
Nothing
else
model.city
in
{ model
| country = Just pickedCountry
, city = newCity
}

CityPicked pickedCity ->
{ model
| city =
model.country
|> Maybe.andThen (always <| Just pickedCity)
}

The branch to handle the CountryPicked (from the dropdown selection) needs to check whether model.country changed, and if it did, reset model.city to Nothing.

Fortunately this original data model is exactly what the view function needs. It passes the selected country and city to the respective dropdowns. And disables the city dropdown if there is no country selected:

view : Model -> Html Msg
view model =
let
(selectedCountry, cityDropDownDisabled) =
case model.country of
Just country ->
(Just country, False)
                Nothing ->
(Nothing, True)
        selectedCity = model.city
in
...

Nice and short and pretty readable.

Version 2: Maybe Maybe — A cleaner data model, without impossible states.

One way to clean up our data model is like this:

type alias Model =
{ destination : Maybe CitySelection }
type alias CitySelection = 
{ country : Country
, city : Maybe City
}

The model may have a destination. And if it does — in the shape of a CitySelection — the destination must have at least a country, and may or may not have a city selected.

This makes it impossible to store a city in the data model if there is no country.

Now the update function would become something like this:

update : Msg -> Model -> Model
update msg model =
case msg of
CountryPicked newCountry ->
{ model
| destination =
case model.destination of
Just { country, city } ->
if newCountry /= country then
Just
{ country = newCountry
, city = Nothing
}
else
Just
{ country = country
, city = city
}
                        Nothing ->
Just
{ country = newCountry
, city = Nothing
}
}

CityPicked newCity ->
{ model
| destination =
case model.destination of
Just { country, city } ->
Just
{ country = country
, city = Just newCity
}
                        Nothing ->
Nothing
}

Oh dear, this really makes the update function quite a bit longer. Sure, there is less room for bugs, because we can never have a selected city without a country, but it is longer. And with all the Just and Nothing branches, it hasn’t really become more readable.

Also not so attractive: this setup forces us to deal with the strange scenario that the user has selected a city from the dropdown, but there was no destination (so no country) in the model yet. In that case we simply leave the destination unchanged as Nothing. This may be useful to catch strange bugs, so I could include a Debug.crash in there. But still, feels like the update function has not really improved..

What about the view function for this data model?

view : Model -> Html Msg
view model =
let
(selectedCountry, cityDropDownDisabled, selectedCity) =
case model.destination of
Just { country, city } ->
(Just country, False, city)

Nothing ->
(Nothing, True, Nothing)
in
...

Pretty short and clean, so that’s good. But is it possible to improve on our update function too?

Version 3: Union Typed — Cleaner data model with union type

There is another way to clean the data model: by using union types.

type alias Model =
{ destination : Destination }
type Destination =
NotChosen
| ToCountry Country
| ToCity Country City

Again, it is impossible to have a city without a country. And now, the data is more readable, because the abstract Just and Nothing are replaced by more real-life terms.

The update function to deal with the dropdown selection now looks like this.

update : Msg -> Model -> Model
update msg model =
case msg of
CountryPicked newCountry ->
{ model
| destination =
case model.destination of
NotChosen ->
ToCountry newCountry
                        ToCountry oldCountry ->
ToCountry newCountry

ToCity oldCountry oldCity ->
if newCountry /= oldCountry then
ToCountry newCountry
else
ToCity oldCountry oldCity
}
        CityPicked newCity ->
{ model
| destination =
case model.destination of
NotChosen ->
NotChosen

ToCountry oldCountry ->
ToCity oldCountry newCity

ToCity oldCountry oldCity ->
ToCity oldCountry newCity
}

Somewhat more readable, but still quite verbose. Mainly because I now have to deal with all three branches of the Destination twice.

Fortunately, the view function in this variant is still quite short and sweet:

view : Model -> Html Msg
view model =
let
(selectedCountry, cityDropDownDisabled, selectedCity) =
case model.destination of
NotChosen ->
(Nothing, True, Nothing)

ToCountry country ->
(Just country, False, Nothing)

ToCity country city ->
(Just country, False, Just city)
in
...

But still, the long update function we have is nagging.

Version 4: Changed Msg — Change the messages too?

One strange thing that happens in the code is that in each version, I have to handle a strange edge case, where the user submits a city to a model that does not have a country. The view does make such a user selection impossible, and the model cannot store it, but the code still has to deal with it.

It is possible to change our Msg structure too, so that the user can only submit valid destinations:

type Msg =
DestinationPicked Destination

Now the update function would really become short and sweet:

update : Msg -> Model -> Model
update msg model =
case msg of
DestinationPicked newDestination ->
{ model
| destination =
newDestination
}

But what this really does, is move the job of creating a new destination to the view function. Both dropdowns now need to send valid DestinationPicked messages whenever the user makes a selection of either a country or a city. In previous variants, the Msg could directly be passed to the dropdown.

type Msg =
CountryPicked Country -- : Country -> Msg
| CityPicked City -- : City -> Msg

But now, something way more complicated is needed in the view function to turn a selection in a dropdown to a message with a destination:

view : Model -> Html Msg
view model =
let
...
countryPickMsg : Country -> Msg
countryPickMsg newCountry =
case model.destination of
NotChosen ->
ToCountry newCountry

ToCountry oldCountry ->
ToCountry newCountry

ToCity oldCountry oldCity ->
if newCountry /= oldCountry then
ToCountry newCountry
else
ToCity oldCountry oldCity

cityPickMsg : City -> Msg
cityPickMsg newCity =
case model.destination of
NotChosen ->
NotChosen

ToCountry oldCountry ->
ToCity oldCountry newCity

ToCity oldCountry oldCity ->
ToCity oldCountry newCity
Note: originally, I dismissed this variant for claiming the logic does not belong in the view. But over on Reddit, NovelTie pointed out that this 4th variant actually has more merit. So I have updated my evaluation below with some new insights I learned from this reponse.

One thing that is immediately obvious, is that this variant does not eliminate the complexity/ necessary logic in our code, but it moves it from the update to our view function.

The country dropdown now needs to know about the selected city, to prevent resetting the city, if the user chooses the same country.

And the city dropdown needs to know about the selected country, to be able to produce a valid new destination-type Msg.

This is not necessarily all bad news: it is the view’s “job” to deduct from the model what kind of user interaction is allowed. And for that, even a single dropdown may actually need more context than just the values it operates on.

Favourite?

Below is a simplified comparison of the variants:

Comparison of variants of the simple example

The first variant is more of a baseline. It allows impossible states, which of course we want to prevent.

For now, I like the Union Type variant best. Even though the update function is longer and more verbose, I find the code easiest to read. In general: giving your Maybe- like types more explicit names makes your code easier to read. So I guess that in this case that has also helped this variant.

However, the 4th variant still has me intrigued as an interesting approach. Originally, I created it to prove that simplification of update functions can be taken too far. But it feels like another theme is emerging: “make impossible updates impossible”…

Better ways to make impossible states impossible?

There are surely better ways to refactor this example and its variants. Extracting the functions from the update function (or the view function in the 4th variant) would surely help.

Responses so far have already proven that better and other variants exist, and can improve readability compared to the solutions I came up with. Feel free to let me know in the comments!

Impossible is nothing
One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.