Groups keyboard shortcuts have been updated
Dismiss
See shortcuts

Proposal: Map.put_if/4

155 views
Skip to first unread message

Juan Manuel Azambuja

unread,
Dec 6, 2024, 9:27:51 AM12/6/24
to elixir-lang-core
Hello,

After working with Elixir for some time I have found myself repeating some patterns when dealing with maps.

One pattern I see repeated constantly in different apps developed by myself or others is adding values to a map conditionally or returning the map unchanged. This comes in different flavors:

Screenshot 2024-12-06 at 11.13.23 AM.png
or
Screenshot 2024-12-06 at 11.14.32 AM.png

When this pattern gets used enough in an app, it's normal to see it abstracted in a MapUtils module that updates the map conditionally if a condition is met or returns the map unchanged otherwise.

My proposal is to include Map.put_if/4 which would abstract the condition check and return the map unchanged if the condition is not met:

Screenshot 2024-12-06 at 11.17.21 AM.png

Enhancing the API by doing this will result in less code and more readable solutions.

Thanks for reading!

José Valim

unread,
Dec 6, 2024, 9:59:40 AM12/6/24
to elixir-l...@googlegroups.com
Hi Juan!

My initial gut feeling is that this approach does not scale. What if you want to delete a key conditionally? Should we have delete_if?

It feels a more general approach would be to introduce `then_if`:

then_if(subject, condition?, function)

Or similar. :)



--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/elixir-lang-core/ed7da716-b9f5-4f64-a77d-d32696326b9en%40googlegroups.com.
Message has been deleted

Ben Wilson

unread,
Dec 6, 2024, 10:30:23 AM12/6/24
to elixir-lang-core
Exploring what that looks concretely in this case:

```
map
|> other_stuff
|> then_if(opts[:foo], &Map.put(&1, :key, value))
```

I like it! Conditional map insert helper functions are definitely something we've written over and over again in our code bases and while it's easy to do, I think in some cases this is cleaner looking than a proliferation of `maybe_put_foo` functions.

- Ben

Zach Daniel

unread,
Dec 6, 2024, 11:41:15 AM12/6/24
to elixir-l...@googlegroups.com
Despite typically being a "put it in the standard library" guy, I don't think that `then_if` actually composes as well as it looks like it does on the tin due to the fact that `then` is often used in pipelines, where some transformation has happened and you want to check a condition *on that result*. For example:

```elixir
changeset
|> do_some_checking()
|> then_if(<is_valid>, &do_more/1)
```

I think that `then` is kind of "already" the composition tool that we need for expressive pipes.

```elixir
changeset
|> do_some_checking()
|> then(fn changeset -> 
  If changeset.valid do
      do_more(changeset)
  else
     changeset
  end
end)
```

I can see an argument that it is very verbose, but its also about as flexible as it can get. My suggestion would be to, if added, have `then_if` take a function as its first argument.

```elixir
changeset
|> do_some_checking()
|> then_if(&(&1.valid?), &do_more/1)
```


José Valim

unread,
Dec 6, 2024, 11:58:21 AM12/6/24
to elixir-l...@googlegroups.com
Thank you Zach. When I wrote the proposal I felt it was missing something still and I think you nailed it.

Passing two anonymous functions would help with the pipeline but it feels it would be detrimental to other cases.



Jim Freeze

unread,
Dec 6, 2024, 12:01:28 PM12/6/24
to elixir-l...@googlegroups.com
then_if has no meaning to me and breaks my brain. 

Seems not to flow with other pipeline commands. 

Dr. Jim Freeze, Ph.D.


Christopher Keele

unread,
Dec 6, 2024, 5:27:32 PM12/6/24
to elixir-lang-core
> One pattern I see repeated constantly in different apps developed by myself or others is adding values to a map conditionally or returning the map unchanged.

I agree this is a wart common with maps in particular (as the out-of-the-box update-often data structure), but the problem is not specific to the Map API; rather, conditional expressions in Elixir.

The intentional design decision for if-and-friends conditionals to honor lexical scoping was not originally part of the language, but added for consistency with other branching structures early on. So you in fact used to be able to just do  map = %{}; if conditional, do: map = Map.put(map, :foo, :bar). Changing this was controversial at the time partially because of this knock-on effect of having to always exhaustively handle all branches of a conditional if assigning results directly to a variable (or otherwise only temporarily branching the control flow of the current scope).

TL;DR you have to do a lot more foo = if ..., else: foo to keep conditional lexical scoping consistent, and I'm in agreement with José that it's that slightly irritating else: foo that (if anything) should be solved holistically at the core language level, rather than extending individual data-structure's APIs.

I don't think we can "solve" else: foo without discussing why it's a problem. I can think of two rationales, but interested in other opinions:

1. Accidentally omitting it can lead to unintentional nil assignments.
2. It is syntactically noisy for what it accomplishes (from the programmer's perspective, literally "nothing"—as in, leaving the assignment in question the same).

In my experience, 1. is not a huge issue, but others may have stronger opinions. It's 2. that makes it a wart. The problem is that there is not much more syntax to strip away from if: no else clause means nil and that cannot reasonably change, and the rest of the macro does not understand that there is a "subject" being assigned to for it to choose to return unchanged. I would propose either introducing a new conditional assignment macro (as discussed a little here already), or consider additional syntaxes for conditionals that makes it a little easier visually to ignore the fallback case.

In either case, as José points out, we need to consider 3 components: a subject to or to not update, a condition, and an action.

New Macro

I agree with the criticisms of then_if. I would rather see something explicitly about updating the subject. Say, a hygine-modifying update_when(subject, condition, fn/block) that required a variable reference subject. Ex:

update_when(map, condition, &Map.put(&1, :key, value))

or

update_when(map, condition) do
Map.put(map, :key, value)
end

The pipe-ability of this is limited by design, but this could still work with then:
changeset
|> do_some_checking()
|> then(fn changeset ->
update_when(changeset, changeset.valid) do
do_more(changeset)
end
end)
|> do_something_else()

Honestly, not in love with this, but I'm slow to warm to these things. We could get cuter with the syntax by overloading guards:

update map when condition do
Map.put(map, :key, value)
end

Reads better, technically parses, but kind of inconsistent with other guard constructs conceptually. Also, how would piping work? Is there a way for this to make sense in a larger pipeline:

map
|> update when condition do
Map.put(map, :key, value)
end

Changing if

Since if cannot be fundamentally aware of a subject, it would have to have a place to specify the default fallback, which else already does in this situation; it's as semantically dense as it can be. To alleviate the noise the fallback block introduces, one option would be to have the if macro accept optional keyword arguments before the block, merging them together, allowing hoisting the trivial else case inline with the condition, independent of the consequent, to create a denser syntax:

map = if condition, else: map do
Map.put(map, :key, value)
end

This also cannot really be piped through without then, but otherwise reads (slightly) nicer than the base case:

changeset
|> do_some_checking()
|> then(fn changeset ->
if changset.valid, else: changeset do
do_more(changeset)
end
end)
|> do_something_else()

It's a really small change that I think pretty much fully addresses the syntactic noise problem. It does lead to this rather odd formulation I'm not sure about:

condition
|> if(else: map) do
Map.put(map, :key, value)
end

Changing case/cond

Of course, we do already have a conditional expression with a semantic notion of a subject, case. However, there's no specific syntax for referencing it, outside clause heads, so the programmer would have to provide it again, similar to the fallback _ -> subject construct today:

map = case map do
%{} -> Map.put(map, :key, value)
_ -> map
end

I think this is orthogonal to the problem we are trying to solve, but if we went the if(conditional, else: fallback) do route, we'd need to consider if we should extend case/cond with similar semantics for consistency's sake, so:

case map, else: map do
%{key: old_value} -> Map.put(map, :key, old_value + 1)
%{} -> Map.put(map, :key, 0)
end

Of course the problem here is that implies the existence of general else clauses in those constructs:

case map do
%{key: old_value} -> Map.put(map, :key, old_value + 1)
%{} -> Map.put(map, :key, 0)
else
map
end

We could implement support this and have it compile down to the correct _ -> map fallback case and warn/error if one was already provided (similarly with true -> map for cond), but generally, not a fan of so many ways to do the same thing.

This is less an argument for adding else to these constructs, and more an argument for calling the keyword argument to if something else less likely to be confused with block semantics. So I'd say that I personally am warmest on the if  proposal alone, and am open to calling the keyword something different and merging it in with the block with the same else duplication warnings/errors we'd need regardless of name, like:

map = if condition, fallback: map do
Map.put(map, :key, value)
end

The update subject when condition do syntax sugar reads very nicely, but feels like it would lead to confusion down the line.

Zach Daniel

unread,
Dec 6, 2024, 5:41:42 PM12/6/24
to elixir-l...@googlegroups.com
Sometimes I remember the Access module and how woefully underutilized it is.

What if we added `Access.skip_if` and `Access.skip_where`?

Usage would look like this:

```elixir
map
|> put_in([Access.skip_if(false), :key], value)
|> put_in([Access.skip_where(fn map -> map.valid? end), :key], value)
```

Wojtek Mach

unread,
Dec 6, 2024, 6:05:11 PM12/6/24
to elixir-l...@googlegroups.com

> On 6 Dec 2024, at 23:27, Christopher Keele <christ...@gmail.com> wrote:
>
> map = if condition, else: map do

This reminds me a lot of with + when:

iex> map = %{a: 1} ; condition = true ; with _ when condition <- map do ; Map.put(map, :b, 2) ; end
%{a: 1, b: 2}
iex> map = %{a: 1} ; condition = false ; with _ when condition <- map do ; Map.put(map, :b, 2) ; end
%{a: 1}

Of course above is not super ergonomic to type (and limits condition to guard expressions) but maybe it is an angle worth exploring.

Christopher Keele

unread,
Dec 6, 2024, 6:07:17 PM12/6/24
to elixir-lang-core
> We could get cuter with the syntax by overloading guards:

update map when condition do
Map.put(map, :key, value)
end

It was pointed out to me that this would either have to work only with guard-compatible conditions or be wildly inconsistent with the rest of the language, so I think this syntax is out

Jean Klingler

unread,
Dec 6, 2024, 8:02:48 PM12/6/24
to elixir-l...@googlegroups.com

Christopher Keele

unread,
Dec 6, 2024, 8:52:17 PM12/6/24
to elixir-lang-core
Created a branch here if folk wanna play with this form and see if it improves readability around conditional re-assignment in their codebases: https://github.com/elixir-lang/elixir/compare/main...christhekeele:elixir:if-else-do-end

map = %{}
condition = true
map = if condition, else: map do
Map.put(map, :key, :value)
end
#=> %{key: :value}

Juan Manuel Azambuja

unread,
Dec 7, 2024, 10:10:16 AM12/7/24
to elixir-lang-core
I like where this discussion is going!

I didn't mention this in my first message, but one way I have solved this is by implementing a macro that looks very similar to Chris' first option.
Personally, I like modifying `if` with `else:` (Chris' second option).

I will give it a spin, but I think it addresses the problem nicely and it doesn't look too weird; I know it does look a bit strange but I think it's just because it's something new and not because it's not well thought.

Yordis Prieto

unread,
Dec 7, 2024, 3:59:09 PM12/7/24
to elixir-lang-core
A `Kernel.when/3` (or whatever name)  would be something interesting, significantly if I can add multiple captures (condition and body)

 |> when(&condition(&1), &Map.put(&1, :something, value))
Message has been deleted

Andrew Timberlake

unread,
Dec 10, 2024, 5:21:34 AM12/10/24
to elixir-lang-core
apply_if/3 ?

On December 6, 2024, Ben Wilson <benwil...@gmail.com> wrote:
Exploring what that looks concretely in this case:

```
map
|> other_stuff
|> then_if(opts[:foo], &Map.put(&1, :key, value))
```

I like it! Conditional map insert helper functions are definitely something we've written over and over again in our code bases and while it's easy to do, I think in some cases this is cleaner looking than a proliferation of `maybe_put_foo` functions.

- Ben


On Friday, December 6, 2024 at 9:59:40 AM UTC-5 José Valim wrote:
Hi Juan!

My initial gut feeling is that this approach does not scale. What if you want to delete a key conditionally? Should we have delete_if?

It feels a more general approach would be to introduce `then_if`:

then_if(subject, condition?, function)

Or similar. :)



On Fri, Dec 6, 2024 at 3:27 PM Juan Manuel Azambuja <ju...@mimiquate.com> wrote:
Hello,

After working with Elixir for some time I have found myself repeating some patterns when dealing with maps.

One pattern I see repeated constantly in different apps developed by myself or others is adding values to a map conditionally or returning the map unchanged. This comes in different flavors:


or



When this pattern gets used enough in an app, it's normal to see it abstracted in a MapUtils module that updates the map conditionally if a condition is met or returns the map unchanged otherwise.

My proposal is to include Map.put_if/4 which would abstract the condition check and return the map unchanged if the condition is not met:



Enhancing the API by doing this will result in less code and more readable solutions.

Thanks for reading!
--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/elixir-lang-core/ed7da716-b9f5-4f64-a77d-d32696326b9en%40googlegroups.com.

--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.

Juan Manuel Azambuja

unread,
Dec 13, 2024, 3:53:43 PM12/13/24
to elixir-lang-core
Hello!

Decided to take at stab at adding then_if, here is a branch in case anybody is curious to try it out. https://github.com/elixir-lang/elixir/compare/main...juanazam:elixir:juanazam_add_then_if?expand=1
I decided to follow Zach's approach and use to anonymous functions, I still have my doubts if using a plain condition would work best, I may try that later.

Screenshot 2024-12-13 at 5.52.56 PM.png

Brandon Gillespie

unread,
Dec 14, 2024, 8:29:49 AM12/14/24
to elixir-l...@googlegroups.com

Peanut gallery here. Perhaps I'm just not doing something right, but personally I prefer to avoid anon functions wherever possible because it makes finding things in debugging much harder as the line numbers aren't as clear as with a named function.

-Brandon

Reply all
Reply to author
Forward
0 new messages