Updating nested records, again

I am sure this has been asked a million times already but I find myself coming back to this problem again and again, so I would like to ask the community about the recommended way to handle this, so I can stop thinking about it.

Consider this model.

model = { foo = { bar = { baz = "hello world" } } }

I want to update foo.bar.baz. How should I do it?

{ model | foo.bar.baz = "hello again" } wont work. I think, foo.bar is synonymous with .bar foo, so it makes sense that I cannot use it on the left side of =.

Right now, I would write the update function like that:

update message model =
    case message of
        Message ->
            let
                foo_bar =
                    foo.bar

                new_foo_bar =
                    { foo_bar | baz = "hello again" }

                foo =
                    model.foo

                new_foo =
                    { foo | bar = new_foo_bar }
            in
                ( { model | foo = new_foo }, Cmd.none )

Generally, I would put every case in its own function to keep the update function itself readable. And I could of course write setter functions that contain alle the record disassembly and reassembly. But that’s just moving this code to another place, it still has to be written.

So, is there a better, more concise way to do this?

Or how about auto-generated setter functions?

3 Likes

See http://faq.elm-community.org/#how-can-i-pattern-match-a-record-and-its-values-at-the-same-time.

This allows you to edit bar. There is deliberately no way to go deeper at once, use a flatter model (usually recommended), or write helper functions.

1 Like

Relevant issue on the compiler repo: https://github.com/elm/compiler/issues/984

I usually create helper funtions:

asBarIn : Foo -> Bar -> Foo
asBarIn foo bar =
    { foo | bar = bar }

Which can then be chained like this:

createBar 123
    |> asBarIn model.foo
    |> asFooIn model

In my experience this has been a nicer pattern than the flipped variant:

setBar : Bar -> Foo -> Foo
setBar bar foo =
    -- or `withBar`
    { foo | bar = bar }
6 Likes

I started doing some update work on a project using monocle lenses, which helps compose functions to get/set nested values. So you would end up something like fooBarBazLens.set "hello again"

My other applications do not have such heavily nested records, so I don’t have a good sense for how applications with and without lenses compare.

We use pipeline like here: https://gist.github.com/s-m-i-t-a/2a83c0bc5b7d7081b019d18520ebc62c
Each level has setter like this: setX: (X -> X) -> Record -> Record, then we can simply use it in pipeline: (setX <| setY <| setZ value) model or model |> (setX <| setY <| setZ value).

1 Like

Thanks a lot for your suggestions.

Composable setters/helpers make sense. I will stick to that.

I’ll also look into the elm-monocle package a bit more, although I’m not entirely sure I would want to use sth. like that in Elm, since I’m not even entirely sure what lenses in Haskell are all about.

On the depth of the model:
The elm-spa-example has a pretty deep model, although the depth is hidden behind types.

Great pattern, thanks! I never thought of doing that.

A little self-advertising here. In my An Outsider’s Guide to Statically Typed Functional Programming, I spend some time writing an Elm lens package like Monocle. I think doing that introduces some useful topics, like designing based on laws. Good to know even if you don’t plan to use lenses.

Further advertising: Lenses for Mere Mortals explains PureScript/Haskell-style lenses. I had to write it in order to understand lenses, which are made to seem way more complicated than they really are. (The same I think is true of monads.)

Note that the second book is free, at least for now.

Excellent question! This is a tricky topic to discuss because the answer is not obvious.

Obvious answers include:

  • Lenses
  • Auto-generated setters
  • New language syntax

I used to think think Elm should add language syntax to make nested record updates easier, but as I’ve spent more time with it I now realize that would be a design mistake. Relatedly, I’ve also come to view lenses as a such a huge mistake that if there were a way for the language to make it impossible to implement lens libraries, I would advocate for it. (Unfortunately, they are not possible to rule out.)

What I’ve realized over time is that the actual answer is this:

Nested record updates are a symptom, not the root problem itself.

Let’s go back to the original question:

Consider this model.

model = { foo = { bar = { baz = "hello world" } } }

I want to update foo.bar.baz. How should I do it?

When I read this part of the quote:

I want to update foo.bar.baz.

…I now have the same reaction as when I read something like this:

I want to mutate window.foo.

In both cases, if I were to respond “sure, here is how to do that” I would not be setting you up for success!

A better answer is “although the language permits doing this, it is so strongly discouraged as a technique that I think we should step back and reexamine the surrounding code to find a fundamentally better way to address your use case.”

There is no quick answer as to how to do this. It requires looking at the code in question and reexamining the relationships between the data model and the functions that operate on it. However, I think doing that is very much worthwhile - and if you’re interested in exploring that path, I think you might be surprised by the outcome!

I’d suggest starting a fresh thread with details on your particular situation (as opposed to foo, bar, etc.) - including the function that wants to do the nested record update, what its arguments are, what the application does, how the types work, and so on.

Good luck!

4 Likes

Hello,

I suggest you pay attention to Richard and other people here saying that simplifying your data may just solve your problem.

…That being said, if you’ve already done that exercise and are really in need for some higher level of abstraction to manipulate nested data blobs, I think lenses are a good way to reduce the burden of accessing and updating data. Someone else has told you about elm-monocle, so I will introduce my own, brand new library:

http://package.elm-lang.org/packages/bChiquet/elm-accessors/latest

In your data example, updating the record would be written as:

newModel = set (foo << bar << baz) "hello again" model

Then you need to define foo, bar and baz, but they all look similar:

foo : Accessor a b c -> Accessor {rec | foo : a} b c
foo (Accessor sub) =
      Accessor { get  = \super -> sub.get super.foo
                      , over = \f -> \super -> { super | foo = sub.over f super.foo }
                      }

by the way, I like to put them in a record in order to avoid polluting the toplevel namespace:

r = {
    bar = \(Accessor sub) ->
      Accessor { get  = \super -> sub.get super.bar
               , over = \f -> \super -> { super | bar = sub.over f super.bar }
               }
    ,
    foo = \(Accessor sub) ->
      Accessor { get  = \super -> sub.get super.foo
               , over = \f -> \super -> { super | foo = sub.over f super.foo }
               }
    }

The benefits of this library, compared to monocle or focus, is that it doesn’t need you to introduce new operators : foo, bar and baz can be composed with the natural << and >> composition operators. I expect this to survive the 0.19 changes better.

(The documentation was just finished yesterday, so feel free to ask questions or comment on it, I will gladly take all the feedback).

I wonder whether foo << onEach bar wouldn’t be better than foo << onEach << bar, the same with try (foo << try bar), otherwise it looks very nice!

If you make try and onEach functions that take a lens rather than lenses, you can not use them as the lowest level lens, unless you give them the id lens:

get (foo << bar << onEach id) myData

I would rather avoid to expose id, since it’s a technical lens that serves no purpose besides being the terminal splice a composition of lenses.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.