Updating structs: Map.put vs %Foo{oldfoo | new: value} vs put_in

Original source of discussion: This topic on the Pragmatic Programmers’ Functional Web Development with Elixir, OTP, and Phoenix forum.

In the (beta version of the) book, they update structs using Map.put/3. I’ve found myself doing the same in my own code. @hubertlepicki astutely observed that this opens up the opportunity for mistyped field names when updating your structs: This is totally allowed, the result of Map.put/3 will then be a map (instead of an instance of your struct).

Example:

iex> defmodule Foo do
iex>   defstruct bar: 1 , baz: 2
iex> end

iex> foo = %Foo{}
iex> foo |> Map.put(:qux, 4)
%{__struct__: Foo, bar: 1, baz: 2, qux: 4}  # <- note that it is printed as a map.

The other two methods known to me to update structs are:

  1. The special struct update syntax, %Foo{oldfoo | bar: 4}. This will result in a compile-time error when wrong field names are used. Drawback: It cannot be piped.
  2. put_in/2: newfoo = put_in(foo.bar, 4). This will also result in a compile-time error when a wrong field name is used. Drawback: It, too, cannot be piped.

put_in/3 (as in: newfoo = put_in(foo, [:bar], 4) would be able to be piped, but it is not available for structs; it only works on things that implement the Access protocol.

So what is the best way to update structs in practice? Am I missing any method in this list? Would it be a good idea to have a version of put_in that works on structs and is pipeable?

7 Likes

The special Form %name{} pipes pretty well I think, you need to wrap in an anonymous funtion though):

iex(1)> defmodule S do
...(1)>   defstruct f: 0
...(1)> end
{:module, S, <<...>>, %S{f: 0}}
iex(2)> s = %S{}
%S{f: 0}
iex(3)> 1 |> (&%S{s | f: &1}).()
%S{f: 1}
iex(4)> s |> (&%S{&1 | f: &1.f + 5}).()
%S{f: 5}
10 Likes

Yes, I raised the issue because I did waste some time on a stupid typo. Generally when I use struct, I want to have only defined fields. Otherwise I’d go with a Map.

But if you do use Map.put/3 on a struct, it works, yet not properly since I mistyped/used wrong key.

I agree that not being able to do it in pipe is not ideal. I found myself wrapping the update syntax in a tiny function/anonymous or othwerwise that does that for the very reason. I don’t know, maybe there is better way somehow so I’m listening too.

2 Likes

My eyes hurt. I do the same, but I die a bit every time I have to write it.

14 Likes

In this post it was just for demonstrational purpose, usually I do it in a def(p)-ed function.

2 Likes

Erlang has :maps.update/3, which is basically the %{x | key => value} syntax as a function. I think having something similar in elixir would be beneficial. The biggest problem is the name.

7 Likes

How about something obvious like:

Struct.update/3 or Struct.put/3

4 Likes

You could also use Map.update!/3 which will pipe nicely and raise if the given key doesn’t exist, though not at compile-time.

3 Likes

We don’t have a Struct module - having it for just one-two functions feels wired.

2 Likes

I can accept @wmnnd’s solution of using Map.update!/3 however.

1 Like

Yes, Map.update! is fine - if only you didn’t have to create a useless anonymous function. It’s awesome in many cases that Map.update! accepts a function, but not for this case.

3 Likes

Would it be an idea then to add a version of Map.update! that does not require you to use a function as last parameter?
Although of course, having both a version that runs a function and a version that puts a value at the key regardless of what it is would mean that functions are basically treated ‘special’, which might be confusing.

There already is another module that has only exactly two functions: Atom, so it does not feel that weird to me to add a new module.

Another approach that might be possible, is to make sure that passing the struct update syntax: foo |> %Foo{bar: 1} would work. I think this could be done by altering the current clauses of the pipeline operator.

2 Likes

@michalmuskala I always thought about put_existing and put_existing! (which neatly mirrors put_new).

11 Likes

https://github.com/bopjesvla/axe supports both piping and structs.

How do you guys feel about adding an access sigil to the core?

1 Like

Huh, OP liked the Axe announcement I posted a few days ago. Now I’m wondering if this was all an elaborate ruse to get me to prostitute myself.

3 Likes

This does not solve the problem of not being able to easily pipe this without wrapping into anonymous function, I think.

1 Like

the syntax inside the access sigil - what is it? Is it your custom invention?

I am asking because I am using something similar yet different in one of my proejcts where I do need to access nested data coming over from json - I did something that resemblex xpath very much instead.

1 Like

Can you explain the scenario where this would not work? Like I mentioned earlier I’m new to the language but in the original poster’s scenario they simply want to update an existing struct’s fields only. My solution includes the ability of piping e.g.

iex> defmodule Foo do
iex>   defstruct bar: 1 , baz: 2
iex> end

iex> foo = %Foo{}
iex> foo |> struct(%{bar: 3, qux: 4)
%Foo{bar: 3, baz: 2} # only existing struct field is updated
4 Likes

Interesting! I did not know about Kernel.struct/2. This seems to work (not being able to insert non-existing fields) and is pipeable. It does have the behaviour of silently disregarding wrong fields rather than raising a (compile-time) error when a wrong field is accessed, which means that the problem of typographical errors in struct field names is not taken care of. I foresee a lot of hard-to-track-down bugs because of this :sweat_smile:.

@josevalim Map.put_existing sounds like a great idea to me.

@bopjesvla I did not at all think about Axe’s access sigil while creating this topic. :grinning: While Axe seems like it solves this problem rather elegantly, I think an access sigil is too heavy a feature to be able to be added to the core language.

3 Likes

Yeah, I just chose something that’s clear in its intention and easy to parse, if I can even call it that. Is your implementation out in the open?

@Qqwy The sigil does not have to be inlined, import Access, only: [sigil_a: 2] with perhaps a use Access shorthand would work just fine.

1 Like