One of the things that makes Elixir so great is its pipe operator. It allows you to chain functions together in a way that is both readable and concise.
For example, you can write:
iex> 1..10
...> |> Enum.map(&(&1 * 2))
...> |> Enum.filter(&Integer.is_even/1)
...> |> Enum.sum()
110
This is much more readable than the equivalent code without pipes:
iex> Enum.sum(Enum.filter(Enum.map(1..10, &(&1 * 2)), &Integer.is_even/1))
110
Even though the second example is not that long, it's still harder to read because you have to read it from the inside out.
You can always break it down into multiple lines, but that can make the code harder to follow as well:
iex> range = 1..10
1..10
iex> doubled = Enum.map(range, &(&1 * 2))
[2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
iex> evens = Enum.filter(doubled, &Integer.is_even/1)
[2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
iex> Enum.sum(evens)
110
So, pipes are great, right? Well, not always... As with any tool, the key is to use the correct tool for the job. And sometimes, pipes are not the right tool.
When You Should Use Pipes
Pipes are great when you have a series of transformations that you want to apply to some data. They make the code more readable and easier to follow.
The key is that each transformation should ideally either:
- Be a pure function that takes some input and returns some output.
- Have no real side effects.
- Does not expect any sort of error handling.
If you can meet these criteria, then pipes are a great choice. They're a very elegant tool for data transformation.
When You Shouldn't Use Pipes
However, there are cases where pipes aren't necessarily the best choice.
If you need error handling, then using pipes would force you to write code similar to this:
@spec register_user(map) :: {:ok, map} | {:error, map}
def register_user(attrs) do
attrs
|> validate_required_fields()
|> validate_email()
|> create_user()
end
@spec validate_required_fields(map) :: {:ok, map} | {:error, map}
def send_email({:ok, user}) do
user
|> validate_email()
|> send_email()
end
def send_email({:error, _reason} = error) do
error
end
iex> %{} |> register_user() |> maybe_send_email()
{:error, ...}
This is code I've seen in the wild and it's not pretty. It's not
bad
per se, but the problem is that you're hard coupling the ability to call
send_email/1
with the output of
register_user/1
.
This can make it harder to reason about your code and can lead to some pretty gnarly bugs, especially if anyone changes
register_user/1
and introduces more invariants.
In this case, it might be better to use a more traditional approach:
case register_user(attrs) do
{:ok, user} ->
case send_email(user) do
{:ok, _} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
Elixir to the Rescue!!
Elixir has a great alternative to the example above: the
with
special form.
with
allows you to chain multiple expressions together and short-circuits if any of them return an error. It's a great way to handle error conditions in a more readable way.
Here's how you could rewrite the example above using
with
:
def register_and_send_email(attrs) do
with {:ok, user} <- register_user(attrs),
{:ok, _res} <- send_email(user) do
{:ok, user}
end
end
If all of the patterns match, then the
with
block returns the last expression. If any of the patterns fail, then the
with
block returns the first failing pattern.
You can also explicitly handle any error cases you care about:
def register_and_send_email(attrs) do
with {:ok, user} <- register_user(attrs),
{:ok, _res} <- send_email(user) do
{:ok, user}
else
{:error, %Ecto.Changeset{} = changeset} ->
{:error, format_changeset_errors(changeset)}
{:error, %SendGridError{} = error} ->
{:error, format_sendgrid_error(error)}
_otherwise ->
# Don't do this in practice!!!
{:error, "Something went wrong"}
end
end
In my opinion,
with
is a much more elegant way to handle error conditions than using pipes. It allows you to keep your code clean and readable while still handling errors in a robust way.
An argument against 'with'
One of the patterns that naturally emerges when using
with
is the "happy path" problem. This is where you end up with a lot of nested
with
blocks that handle the happy path, but it can be very difficult to handle the error cases if you care explicitly about
which
case failed.
Pattern matching goes a long way, but this depends on the errors raised by the functions you're calling. If any of them just return
{:error, String.t()}
or return the same type of error, then you're going to have a bad time.
It can be tempting to "tag" the functions you're calling in the
with
statement, which ends up looking like this:
def register_and_send_email(attrs) do
with {1, {:ok, user}} <- {1, register_user(attrs)},
{2, {:ok, _res}} <- {2, send_email(user)} do
{:ok, user}
else
{_, {:error, %Ecto.Changeset{} = changeset}} ->
{:error, format_changeset_errors(changeset)}
{_, {:error, "Something went wrong"}} ->
{:error, "Something went wrong"}
{1, _error} ->
retry_send_email_in_background_job(user)
end
end
The problems with this approach are:
- You've just tanked the readability of the function.
-
Any case you don't handle in the
else
block will raise aMatchError
. - You now can't simply propagate errors upwards without some sort of transformation, which is error prone and can lead to unexpected behaviour.
An underused pattern
One place in
Vetspire
's codebase where we ended up with very gnarly
with
statements involved authorization checks in our GraphQL resolvers.
We had a lot of code that looked like this:
def resolve(_parent, args, %{context: %{current_user: user}}) do
with {:ok, %User{} = user} <- Users.get_user(user.id),
true <- Users.authorized?(user, :update, args.resource),
{:ok, resource} <- Resource.get_resource(args.resource),
true <- user.org_id == resource.org_id do
# Do the thing
else
false ->
{:error, :unauthorized}
{:error, reason} ->
{:error, reason}
end
end
Not only would I argue that this code is ugly and hard to read , but it's also hard to reason about .
If you ever wanted to explicitly handle errors separately, you'd have to do some pretty gnarly pattern matching. Moreover, if you ever wanted to add more checks, you'd have to add more
with
clauses, which would make the function even harder to read.
Arguably we're opening ourselves up to some timing attacks here, but that's a different discussion.
Regardless, we've actually been rewriting our resolver-level authorization checks to use a different pattern: the much underused
cond
special form!
def resolve(_parent, args, %{context: %{current_user: user}}) do
user = Users.get_user!(user.id)
resource = Resource.get_resource!(args.resource)
cond do
nil in [user, resource] ->
{:error, :not_found}
not Users.authorized?(user, :update, resource) ->
{:error, :unauthorized}
not user.org_id == resource.org_id ->
{:error, :unauthorized}
true ->
# Do the thing
end
end
This is much more readable and easier to reason about. It's also easier to add more checks if you need to.
The way
cond
works is by expanding to a bunch of
if
statements, so this code is identical to how you might write this in a more traditional language.
The main win here is that it is explicit. I can see all of the checks that are being made in one place, and I can see what the error cases are.
I'm also loading all the data upfront if it exists, so there's less of a timing issue here (though
Users.authorized?/3
might still introduce a subtle one depending on its implementation).
I'm not sure why you don't often see
cond
s used like this in the wild, but I think it's a great pattern that deserves more love.
It's all about Context
Elixir is a very flexible language, and there are many ways to solve the same problem. The key is to use the right tool for the job.
This depends on the context of the problem you're trying to solve. If you're doing a series of data transformations, then pipes are a great choice. If you're handling error conditions, then
with
or
cond
might be a better choice.
If I look at the Elixir code I write now versus the code I wrote when I first started learning Elixir, it's very different.
One question you should ask yourself is: "do I even need to handle error cases?". If you don't then you should embrace "let it crash".
What I mean by this is, sometimes crashing is literally what you want to do. A lot of our Oban workers are written in this way. They will
try .. catch .. rescue .. after
some simple business logic function which will raise if anything goes wrong.
We can then intercept the errors we care about at the top level and snooze, retry, or cancel the job as appropriate. Unhandled errors will just crash the worker, which will be handled by Oban itself.
Doing this lets you write your code in a more assertive style like so:
@impl Oban.Job
def perform(%Oban.Job{args: %{org_id: org_id, user_id: user_id, resource_id: resource_id}}) do
%User{org_id: ^org_id} = Users.get_user!(user_id)
%Resource{org_id: ^org_id} = Resource.get_resource!(resource_id)
if Users.authorized?(user, :update, resource) do
# Do the thing
else
# Cancel the job, log an error, whatever!
end
rescue
_error ->
# Handle the error however we need to...
end
I find that doing this is the most readable and maintainable way to write Elixir code. There's nothing fancy going on at all (except for the implicit
rescue
block, but that's optional).
This is also the pattern we use for any code we write in a database transaction as we can rely on the transaction to rollback and/or return the error for us:
@spec my_transaction(org_id :: integer(), user_id :: integer(), resource_id :: integer()) :: {:ok, term()} | {:error, term()}
def my_transaction(org_id, user_id, resource_id) do
Repo.transaction(fn ->
%User{org_id: ^org_id} = Users.get_user!(user_id)
%Resource{org_id: ^org_id} = Resource.get_resource!(resource_id)
if Users.authorized?(user, :update, resource) do
# Do the thing
else
Repo.rollback(:unauthorized)
end
end)
end
Again, this is explicit and easy to reason about. It's also very easy to add more checks if you need to.
Conclusion
Elixir is a very flexible language, and there are many ways to solve the same problem. The key is to use the right tool for the job.
I find that a lot of people new to the language will usually go through a pipe-everything phase, a with-everything phase, before settling on a more nuanced approach.
Hopefully, this article has given you some food for thought on how to handle error conditions in your Elixir code.