Nested records and defensive programming

· Allanderek's blog


A fairly common problem for an Elm developer to encounter after around 6 months is that nested record update is a little painful. Suppose you have a form on your model, like this:

 1type alias CommentForm =
 2    { content : String
 3    , subject : String
 4    , visibleToAll : Bool
 5    }
 6type alias Model =
 7    { route : Route
 8    , commentForm : CommentForm
 9    , ...
10    }

So now you want to handle the message for updating the comment form. So you might have a handler like this:

 1update message model =
 2    case message of
 3        Msg.CommentFormContent input ->
 4            let
 5                commentForm =
 6                    model.commentForm
 7            in
 8            ( { model | { commentForm | content = input } }
 9            , Cmd.none
10            )
11        ...

There are a couple of solutions to this, for example perhaps you should factor out the messages which update only the comment form, or another solution is to avoid the nested record in the first place. Just have all of the comment form fields on the actual main model. If you need to restrict a given function's input for some reason (usually to reuse elsewhere) then you can use extensible record update. So in this case you have:

 1type alias Model =
 2    { route : Route
 3    { commentFormContent : String
 4    , commentFormSubject : String
 5    , commentFormVisibleToAll : Bool
 6    , ...
 7    }
 8update message model =
 9    case message of
10        Msg.CommentFormContent input ->
11            ( { model | commentFormContent = input }
12            , Cmd.none
13            )
14        ...

For some reason I'm not overly keen on the commentForm prefix, somehow I like that being more formal, but anyway the thing I wanted to express today was about defensive programming. Now suppose you want to write the update handler for the successful response to posting the the comment. At this point you want to empty the comment form, ready for the next comment. Let's try this in both styles, first with a nested record:

 1-- We write this separately and it can be used in the `init` function to
 2-- to initialise the comment form on the model.
 3emptyCommentForm : CommentForm
 4emptyCommentForm =
 5    { content = ""
 6    , subject = ""
 7    , visibleToAll = False
 8    }
 9
10update message model =
11    case message of
12    ...
13        SubmitCommentFormResponse (Ok _) ->
14            ( { model | commentForm = emptyCommentForm }
15            , Cmd.none
16            )
17    ...

How would you do this with a flat model structure? Well we can use extensible records to write the emptyCommentForm:

 1type alias CommentForm a =
 2    { a
 3        | commentFormContent : String
 4        , commentFormSubject : String
 5        , commentFormVisibleToAll : Bool
 6    }
 7type alias Model =
 8    CommentForm
 9        { route : Route
10        , ...
11        }
12emptyCommentForm : CommentForm a -> CommentForm a
13emptyCommentForm model =
14    { model
15        | commentFormContent = ""
16        , commentFormSubject = ""
17        , commentFormisibleToAll = False
18        }
19
20update message model =
21    ...
22    case message of
23        SubmitCommentFormResponse (Ok _) ->
24            ( model |> emptyCommentForm
25            , Cmd.none
26            )
27    ...

Okay so this is quite nice. I like the update function, you can even define a popular helper function to return the model without any commands and use the right pizza operator:

 1update message model =
 2    let
 3        noCommands m =
 4            ( m, Cmd.none )
 5    in
 6    ...
 7    case message of
 8        SubmitCommentFormResponse (Ok _) ->
 9            model 
10                |> emptyCommentForm
11                |> noCommands
12    ...

However, this approach has two significant drawbacks. I've already hinted at the first. Using nested records the emptyCommentForm is not a function but just a record value and can therefore be used to initialise the model in your application's init function. Using the extensible record style you cannot use this in your init function, you could call it with your initial model to make sure that it is always empty in the same way, but you still need to input some values for the commentForm-prefixed fields.

The second drawback however, is why I think this style hurts defensive programming a little. It concerns adding a field to the comment form. Suppose you wish to add a field which is the comment to which you are replying. In the nested record style:

1type alias CommentForm =
2    { content : String
3    , subject : String
4    , visibleToAll : Bool
5    , replyingTo : Maybe CommentId
6    }

If you make this one change, the Elm compiler will complain to you that your emptyCommentForm is no longer valid, because it doesn't define the replyingTo field. Fix this and your init function and the handler for ubmitCommentFormResponse (Ok _) are both automatically fixed.

However, if you do this in the extensible record style

 1type alias CommentForm a =
 2    { a
 3        | commentFormContent : String
 4        , commentFormSubject : String
 5        , commentFormVisibleToAll : Bool
 6        , commentFormReplyingTo : Maybe CommentId
 7    }
 8type alias Model =
 9    CommentForm
10        { route : Route
11        , ...
12        }

Unfortunately here you will get no help, because the unchanged emptyCommentForm function is still a valid CommentForm a -> CommentForm a function.

So for this reason I find the nested record style is sometimes the more defensive style. Of course each situation varies and sometimes you have no 'emptying' of a record to do anyway. Still, this is worth bearing in mind when choosing your data-structures.