Elixir's With Statement
A reason that I'm such a huge fan of Elixir is that everything just seems to click. The code is elegant and expressive while managing to be efficient.
One thing that hasn't clicked until just now is the with
statement. It turns out that it's pretty easy to understand and really quite useful. It addresses a specific and not-uncommon problem in a very Elixir-way. What's the problem? Pretend we have the following:
defmodule User do
defstruct name: nil, dob: nil
def create(params) do
end
end
The create
function should either create a User
struct or return an error if the params
are invalid. How would you go about doing this? Here's an example using pipes:
def create(params) do
%User{}
|> parse_dob(params["dob"])
|> parse_name(params["name"])
end
defp parse_dob(user, nil), do: {:error, "dob is required"}
defp parse_dob(user, dob) when is_integer(dob), do: %{user | dob: dob}
defp parse_dob(_user, _invalid), do: {:error "dob must be an integer"}
defp parse_name(_user, {:error, _} = err), do: err
defp parse_name(user, nil), do: {:error, "name is required"}
defp parse_name(user, ""), do: parse_name(user, nil)
defp parse_name(user, name), do: %{user | name: name}
The problem with this approach is that every function in the chain needs to handle the case where any function before it returned an error. It's clumsy, both because it isn't pretty and because it isn't flexible. Any new return type that we introduced has to be handled by all functions in the chain.
The pipe operator is great when all functions are acting on a consistent piece of data. It falls apart when we introduce variability. That's where with
comes in. with
is a lot like a |>
except that it allows you to match each intermediary result.
Let's rewrite our code:
def create(params) do
with {:ok, dob} <- parse_dob(params["dob"]),
{:ok, name} <- parse_name(params["name"])
do
%User{dob: dob, name: name}
else
# nil -> {:error, ...} an example that we can match here too
err -> err
end
end
defp parse_dob(nil), do: {:error, "dob is required"}
defp parse_dob(dob) when is_integer(dob), do: {:ok, dob}
defp parse_dob(_invalid), do: {:error "dob must be an integer"}
defp parse_name(nil), do: {:error, "name is required"}
defp parse_name(""), do: parse_name(nil)
defp parse_name(name), do: {:ok, name}
Every statement of with
is executed in order. Execution continues as long as left <- right
match. As soon as a match fails, the else
block is executed. Within the else
block we can match against whatever WAS returned. If all statements match, the do
block is executed and has access to all the local variables in the with
block.
Got it? Here's a test. Why did we have to change our success cases so that they'd return {:ok, dob}
and {:ok, name}
instead of just dob
and name
?
If we didn't return {:ok, X}
, our match would look like:
def create(params) do
with dob <- parse_dob(params["dob"]),
name <- parse_name(params["name"])
do
%User{dob: dob, name: name}
else
err -> err
end
end
...
However, a variable on the left side matches everything. In other words, the above would treat the {:error, "BLAH"}
as matches and continue down the "success" path.
If you found this useful, check out my short series Elixir, A Little Beyond The Basics to get more fundamental understanding of Elixir.