Impossible states and stale messages

· Allanderek's blog


There are two quite common pieces of advice for Elm programmers:

  1. Make impossible states impossible
  2. Avoid carrying state state in your messages

Impossible states impossible #

The first is a call to carefully consider the types, mostly in your model and messages. So do not have a Maybe User type used anywhere where you consider it impossible for the value to be Nothing. So for example, you might have a message, such as the liking of a post, that requires a logged in user, because the request takes in the authorisation token:

 1likePost : AuthToken -> Post.Id -> Cmd Msg
 2listPost authToken postId =
 3    ...
 4
 5type Msg
 6    = ...
 7    | LikePost Post.Id
 8      ...
 9
10type alias Model =
11    { mUser : Maybe User
12    , ...
13    }
14
15update : Msg -> Model -> (Model, Cmd Msg)
16update message model =
17    case message of
18        ...
19        LikePost postId ->
20            let
21                token =
22                    model.mUser
23                        |> Maybe.map .token
24                        |> Maybe.withDefault "" -- ARGH
25                command =
26                    likePost token postId
27            in
28            ...
29
30viewPost : Model -> Post -> Html Msg
31viewPost model post =
32    ...
33    likeButton =
34        case model.mUser of
35            Nothing ->
36                Html.nothing
37            Just _ ->
38                Html.button
39                    [ LikePost post.Id |> Events.onClick ]
40                    [ Html.text "Like" ]
41    ...

The -- ARGH line is supposed to be impossible because we should only render the like button if the user is logged in. We can improve this by forcing the view function to check whether the user is logged in, and we can do that by carrying the token or the whole user in the LikePost message (using the whole user prevents you from still applying the LikePost message with the empty token):

 1likePost : AuthToken -> Post.Id -> Cmd Msg
 2listPost authToken postId =
 3    ...
 4
 5type Msg
 6    = ...
 7-    | LikePost Post.Id
 8+    | LikePost User Post.Id
 9      ...
10
11type alias Model =
12    { mUser : Maybe User
13    , ...
14    }
15
16update : Msg -> Model -> (Model, Cmd Msg)
17update message model =
18    case message of
19        ...
20-        LikePost postId ->
21+        LikePost user postId ->
22            let
23-                token =
24-                    model.mUser
25-                        |> Maybe.map .token
26-                        |> Maybe.withDefault "" -- ARGH
27                command =
28-                    likePost token postId
29+                    likePost user.token postId
30            in
31            ...
32
33viewPost : Model -> Post -> Html Msg
34viewPost model post =
35    ...
36    likeButton =
37        case model.mUser of
38            Nothing ->
39                Html.nothing
40-            Just _ ->
41+            Just user ->
42                Html.button
43-                    [ LikePost post.Id |> Events.onClick ]
44+                    [ LikePost user post.Id |> Events.onClick ]
45                    [ Html.text "Like" ]
46    ...

Now it's really impossible for the LikePost message to be instantiated without a valid user. This seems like a solid win.

Avoid stale messages #

I've written before about stale messages. The problem is that you're basically duplicating some of the state of the model in the message, so if you're unfortunate and the model is updated and a second message is invoked before the view is re-rendered, this can lead to bugs. I've found that this can happen when a browser's auto-fill causes a bunch of update messages to be sent essentially simultaneously. In the scenario above, suppose you also want to update the number of likes on the post, something like this:

 1update : Msg -> Model -> (Model, Cmd Msg)
 2update message model =
 3    case message of
 4        ...
 5        LikePost user postId ->
 6            let
 7                newPosts =
 8                    case Dict.get postId model.posts of
 9                        Nothing ->
10                            model.posts -- ARGH
11                        Just post ->
12                            Dict.insert postId { post | likes = post.likes + 1 } model.posts
13
14                command =
15                    likePost user.token postId
16            in
17            ( { model | posts = newPosts }
18            , command
19            )

Again, the -- ARGH line should be impossible. We can do the same trick as above, and pass the entire post into to the LikePost message:

 1type Msg
 2    = ...
 3-    | LikePost User Post.Id
 4+    | LikePost User Post
 5      ...
 6update : Msg -> Model -> (Model, Cmd Msg)
 7update message model =
 8    case message of
 9        ...
10-        LikePost user postId ->
11+        LikePost user post ->
12            let
13                newPosts =
14-                    case Dict.get postId model.posts of
15-                        Nothing ->
16-                            model.posts -- ARGH
17-                        Just post ->
18-                            Dict.insert postId { post | likes = post.likes + 1 } model.posts
19+                    Dict.insert postId { post | likes = post.likes + 1 } model.posts
20
21                command =
22                    likePost user.token postId
23            in
24            ( { model | posts = newPosts }
25            , command
26            )
27
28viewPost : Model -> Post -> Html Msg
29viewPost model post =
30    ...
31    likeButton =
32        case model.mUser of
33            Nothing ->
34                Html.nothing
35            Just user ->
36                Html.button
37-                    [ LikePost user post.Id |> Events.onClick ]
38+                    [ LikePost user post |> Events.onClick ]
39                    [ Html.text "Like" ]
40    ...

Great, now the whole post is given to the LikePost message and we needn't worry about the possibility that the post does not exist. But now your message is carrying possibly stale data. What if, for example, you re-download the list of posts on a periodic basis. If this just happens to arrive in the same animation frame as when the like button is clicked you can get into a race condition where the receipt of the download updates the posts, but the particular post that was liked is then overwritten with stale data.

You could mitigate this by checking in the dictionary of posts and only using the one in the message if the post is not there. However, you need to consider; is it really impossible that the post is not there, if you're re-downloading the list of posts periodically, then that post may have been deleted. The question is what would you want to do in that case? I'd argue that rather than re-inserting the stale data with an updated like count, what you want to do is accept that the post has been deleted and just allow that like to disappear into the ether. That's what happens with the original code (though the original code would still send the like to the server, again that could be reasonable behaviour, depending on what the server does if you like a deleted post).

Conclusion #

So both of these mantras are good proverbs to live by. They sometimes conflict, and when they do that means you have a decision to make. It's relatively unlikely that you update the user very often or quickly enough for the the stale message data to be a problem (though you could only store the 'token' in the message), so it seems here that guarding against the impossible state is a reasonable choice. However, storing the entire post in the message seems like you're more likely to introduce a stale data bug so perhaps accepting the fact that you cannot type-your-way out of an impossible state is the reasonable choice. Also perhaps it's not so impossible a state anyway.