Your digital product needs to perform, or you risk losing users. Elixir is the fault-tolerant tool that can help. Book a free consult today to learn more.
Dipping Our Toes into the Problem
At work, quite often I want to update nested maps or keywords. This happens usually when having an options \\ []
argument to a function where you would like to provide some default values. For example, when working with live_isolated_component
, we would declare a helper function like:
def live_my_component(opts \\ []) do
opts = opts |> set_default_assigns(opts) |> set_default_slots(opts)
live_isolated_component(MyAppWeb.Components.MyComponent, opts)
end
The options to live_isolated_component
are many, but :assigns
and :slots
are keywords themselves, so updating them can be more convoluted, as we would like to protect the given data. This is because of how Map.update/4
and Keyword.update/4
(and their variants Map.update!/3
and Keyword.update!/3
respectively) work. To understand what the issue is, let’s dive into these functions workings first:
X.update/4
and X.update!/3
The update/4
functions (without a final bang) accept four parameters. The first is the collection (the map or the keyword), the second is the key to update, and the fourth is the function to update the value of the given key should it exist. If it does not exist, the third argument is the value and the key is inserted.
The update!/3
functions work pretty much the same, but they raise a KeyError
if the key does not exist.
# Our setup
map = %{a: 1, b: %{ba: 2, bb: 3}, c: 3} |> IO.inspect()
# %{a: 1, b: %{ba: 2, bb: 3}, c: 3}
keyword = [a: 1, b: %{ba: 2, bb: 3}, c: 3, a: 2] |> IO.inspect()
# [a: 1, b: %{ba: 2, bb: 3}, c: 3, a: 2]
map |> Map.update(:a, :not_found, &(&1 + 1)) |> IO.inspect()
# %{a: 2, b: %{ba: 2, bb: 3}, c: 3}
map |> Map.update(:d, :not_found, &(&1 + 1)) |> IO.inspect()
# %{a: 1, b: %{ba: 2, bb: 3}, c: 3, d: :not_found}
map |> Map.update!(:a, &(&1 + 1)) |> IO.inspect()
# %{a: 2, b: %{ba: 2, bb: 3}, c: 3}
map |> Map.update!(:d, &(&1 + 1)) |> IO.inspect()
# ** (KeyError) key :d not found in: %{a: 1, b: %{ba: 2, bb: 3}, c: 3}
# :erlang.map_get(:d, %{a: 1, b: %{ba: 2, bb: 3}, c: 3})
# (elixir 1.14.5) lib/map.ex:319: Map.update!/3
# iex:x: (file)
keyword |> Keyword.update(:a, :not_found, &(&1 + 1)) |> IO.inspect()
# [a: 2, b: %{ba: 2, bb: 3}, c: 3]
keyword |> Keyword.update(:d, :not_found, &(&1 + 1)) |> IO.inspect()
# [a: 1, b: %{ba: 2, bb: 3}, c: 3, a: 2, d: :not_found]
keyword |> Keyword.update!(:a, &(&1 + 1)) |> IO.inspect()
# [a: 2, b: %{ba: 2, bb: 3}, c: 3]
keyword |> Keyword.update!(:d, &(&1 + 1)) |> IO.inspect()
# ** (KeyError) key :d not found in: [a: 1, b: %{ba: 2, bb: 3}, c: 3, a: 2]
# (elixir 1.14.5) lib/keyword.ex:1115: Keyword.update!/4
# (elixir 1.14.5) lib/keyword.ex:1111: Keyword.update!/4
# iex:x: (file)
Beware with
Keyword.update!/3
andKeyword.update/4
because they not only update the latest value for the key, but also remove all the other values for that key! You can check it in the examples above. When updating the key:a
the other values for it got removed! That did not happen when successfully updating the key:d
.
The Problem when Updating Nested Maps / Keywords
Usually, options for a function are expressed as a Keyword structure. Sometimes, these options contain nested structures. Some examples we encounter often include when we’re working with form_data
using Phoenix.LiveViewTest.form/3
or the options in my library live_isolated_component
.
In these cases, the default value in update
can be kind of problematic. Let’s see an example using the options for LiveIsolatedComponent.live_isolated_component/2
.
One of these options is assigns
which can be a map with the given assigns to the component. When a component is medium or large, you might want to extract the call to live_isolated_component/2
to a function, so you can define some default attributes so the test can focus on the important data. One way of doing this using Map.update/4
would be this:
new_options = Map.update(
options,
:assigns,
%{page: insert(:page), author: insert(:user), type: :event},
fn assigns ->
assigns
|> Map.put_new_lazy(:page, fn -> insert(:page) end)
|> Map.put_new_lazy(:author, fn -> insert(:author) end)
|> Map.put_new(:type, :event)
end
)
In this example, we can see the code is now duplicated. We have the default values both in the third argument and then inside the function. This case is pretty common to my day-to-day work and it annoyed me, but there is a simpler way to fix it:
new_options =
options
|> Map.put_new(:assigns, %{})
|> Map.update!(:assigns, fn assigns ->
assigns
|> Map.put_new_lazy(:page, fn -> insert(:page) end)
|> Map.put_new_lazy(:author, fn -> insert(:author) end)
|> Map.put_new(:type, :event)
end)
In our case, if the default value in update/4
would be passed to the function, the problem would be fixed. Though the function does not behave that way, we can cheat it by using Map.put_new/3
, Map.put_new_lazy/3
, Keyword.put_new/3
, and Keyword.put_new_lazy/3
. With that, we make sure a value exists for the desired key. Then, we can call update!/3
knowing it won’t raise.
Conclusion
Sometimes we might be looking in the standard API for exactly the function we are looking for just to find a lot of functions that are close to what we want, but not really what we need. In the case above, using the standard function as is would require writing code that is more complex and harder to maintain, as we would have to apply the change in two separate places (the default value and the update function itself). But we have also seen that with a bit of thought, we can use the provided utilities as building blocks and, by reaching for something seemingly unrelated, we can write our code on top of the standard API.
So, if you think something is not provided by Elixir, look again, you might just need to combine a few functions to get your desired behavior.