EDIT: CapturePipe has been released as a library!
Please give it a go and let us know if you find any mistakes.
Hi everyone!
Yesterday evening (it was already late… ) I was reflecting a bit about common patterns in my Elixir code.
If you’re anything like me, you’ll often end up with functions that look like this:
def some_function(argument, other_argument) do
changed_argument =
argument
|> other_function(other_argument)
|> more_work(42)
{:ok, changed_argument}
end
This type of code is highly prevalent when working with ok/error
-tuples, but also when working with GenServers or anything based on them (like e.g. Phoenix Channels or Phoenix LiveView handlers) because the callbacks of these behaviours require you to return tuples all the time as well. An example is:
def handle_event("reset-button", _params, socket) do
new_socket =
socket
|> assign(:form_values, default_form_values())
|> assign(:error_messages, [])
|> assign(:reset_button_active, false)
{:noreply, new_socket}
end
This inserting into tuples (and sometimes other datastructures) is in tension with working with pipelines. It requires ‘breaking the pipeline’. Flow no longer goes left-to-right, top-to-bottom but rather back to the top (where some new variable is bound) and then continues below the pipeline.
Yesterday evening I realized that there was a tiny sprinkle of syntactical sugar that would resolve this tension:
defmodule Capturepipe do
@doc """
A pipe-operator that extends the normal pipe
in one tiny way:
It allows the syntax of having a bare `&1` capture
to exist inside a datastructure as one of the pipe results.
This is useful to insert the pipe's results into a datastructure
such as a tuple.
What this pipe-macro does, is if it encounters a bare `&1` capture,
it wraps the whole operand in `(&(...)).()` which is the
anonymous-function-call syntax that the Kernel pipe accepts,
that (argubably) is much less easy on the eyes.
So `10 |> {:ok, &1}` is turned into `10 |> (&({:ok, &1})).()`
To use this operator in one of your modules, you need to add the following to it:
import Capturepipe
import Kernel, except: [|>: 2]
## Examples
Still works as normal:
iex> [1,2,3] |> Enum.map(fn x -> x + 1 end)
[2,3,4]
Insert the result of an operation into a tuple
iex> 42 |> {:ok, &1}
{:ok, 42}
It also works multiple times in a row
iex> 20 |> {:ok, &1} |> [&1, 2, 3]
[{:ok, 20}, 2, 3]
"""
defmacro prev |> next do
# Make sure the pipes are expanded left-to-right (top-to-bottom)
# to allow consecutive applications of the capturepipe to work
prev = Macro.expand(prev, __CALLER__)
# Perform change only if we encounter a `&1` that is not wrapped in a `&(...)`
{_, visible?} = Macro.postwalk(next, false, &capture_visible?/2)
if visible? do
quote do
Kernel.|>(unquote(prev), (&(unquote(next))).())
end
else
quote do
Kernel.|>(unquote(prev), unquote(next))
end
end
end
@doc false
def capture_visible?(ast = {:&, _, [1]}, _bool), do: {ast, true}
def capture_visible?(ast = {:&, _, _}, _bool), do: {ast, false}
def capture_visible?(ast, bool), do: {ast, bool}
end
This allows us to write above example snippet as
def handle_event("reset-button", _params, socket) do
socket
|> assign(:form_values, default_form_values())
|> assign(:error_messages, [])
|> assign(:reset_button_active, false)
|> {:noreply, &1}
end
Now I know that opinions on enhancing the pipe-operator in general are divided.
Personally I think that this tiny bit of sugar is easier to understand (especially for people seeing the code for the first time!) than e.g. defining manual ok(...)
or noreply(...)
wrapping functions and I think it can be an improvement on the ‘breaking of the pipeline’ that is currently required.
That said, I am not (yet) releasing this snippet as a library, because:
- even though it is a tiny bit of sugar and macro-code, it might still be somewhat brittle.
- I’d rather start a bit of discussion about this syntax to hear what other people think about this sleep-deprived idea I had yesterday-evening late before committing to it .
Your input and feedback is greatly appreciated!