Elixir's Secret Weapon

I recently began using a new(ish) feature of Elixir that completely transformed the way I build programs. I’m talking about the special form with. It can feel unfamiliar at first, but it is extremely powerful and flexible. This article will explain how with works and how it can be used to make your code more robust to errors. First, though, let’s look at the problem with is trying to solve.

Tagged Tuples

The Elixir community has borrowed an idiom from the Erlang community called tagged tuples. Using this approach, our functions will return a tuple where the first value is an atom describing the “type” of the result. A common pattern is returning {:ok, value} for a successful response and {:error, some_error} for an error response.

defmodule Math do
def divide(_, b) when b == 0, do: {:error, "divide by zero"}
def divide(a, b), do: {:ok, a / b}
end

case Math.divide(1, 2) do
{:ok, result} -> "success: #{result}"
{:error, error} -> "error: #{error}"
end
# => "success: 0.5"

This is nice for a few reasons. First, it allows us to treat errors as values. We know that our function will always return even when provided semantically invalid data. Second, we can use pattern matching to act explicitly on both the success and failure case.

This patterns starts to break down when we want to perform multiple, dependent actions that can all fail. Suppose, for example, that we’d like to divide two numbers and, if successful, divide the result by a third number.

case Math.divide(1, 2) do
{:ok, result} ->
case Math.divide(result, 4) do
{:ok, result} -> "success: #{result}"
{:error, error} -> "error: #{error}"
end
{:error, error} -> "error: #{error}"
end
# => "success: 0.125"

This works, sure, but it’s becoming challenging to read. You can easily imagine the mess this becomes with an arbitrarily long list of dependent operations.

What should we do? We want to continue using this pattern because it is safe and explicit but it feels ugly and unreadable with real-world examples. The answer, of course, is to use with.

Using with

The with special form allows you to define a set of operations to perform and associated patterns to match their results against. Each operation can use bindings from the pattern match of the previous operations. If any of the matches fail, the entire with form stops and that non-matching result is returned. Let’s explore by rewriting the above example using with.

with {:ok, a} <- Math.divide(1, 2),
{:ok, b} <- Math.divide(a, 4) do
"success: #{b}"
end
# => "success: 0.125"

Let’s walk through the execution. First, we call Math.divide(1, 2). The result is {:ok, 0.5}. The with form checks to see if this matches the pattern on the lefthand side of <-. It does, so the variable a is bound to 0.5 and execution continues. On the second line, we run Math.divide(0.5, 4) (because a is now bound to 0.5). This returns {:ok, 0.125}. We check if it matches the pattern on the lefthand side of its <-. It does, so b is bound to 0.125. There are no more operations to perform, so the body of the do block is executed. This do block can use any of the bindings from the with operations above. It uses b to return the string "success: 0.125".

Now, let’s try walking through an error case.

with {:ok, a} <- Math.divide(1, 0),
{:ok, b} <- Math.divide(a, 4) do
"success: #{b}"
end
# => {:error, "divide by zero"}

First, we call Math.divide(1, 0). The result is {:error, "divide by zero"}. We check to see if this matches the patter on the left of <-. It doesn’t! As soon as this mismatch occurs, the with form immediately stops executing further operations and returns the result that did not match. Therefore, the return value of the form is {:error, "divide by zero"}.

Better Error Handling

We’re already in a better spot by using with but we can go even further. The with form allows us to describe an else clause that handles any non-matching values rather than simply returning them. Let’s add one.

with {:ok, a} <- Math.divide(1, 0),
{:ok, b} <- Math.divide(a, 4) do
"success: #{b}"
else
{:error, error} -> "error: #{error}"
end
# => "error: divide by zero"

Now, when the first operation returns {:error, "divide by zero"} and it doesn’t match the pattern, the value is passed to the else block. Next, we check each clause of the else block in order (in this example there is only one clause). The first clause matches {:error, "divide by zero"}, so the string "divide by zero" is bound to error. Finally, the body of that clause is executed with the bindings from the match. It returns the string "error: divide by zero".

More

The with form allows even more flexibility, including guard clauses in each of the operations. For more examples see the docs.