Dating back to version 1.2, the with
operator is one of Elixir’s features that need a bit of time to comprehend at first. It often gets used in situations where one would use case
, or vice versa. The main difference between the two is that with
will fall through, if no clause is matched, while case
will throw a no-match error.
Confused? Let’s start with some basics.
Use case
when you need to perform exhaustive pattern matching, and ensure that at least one of the conditions matches:
case foo() do
cond1 -> expression1
cond2 -> expression2
cond3 -> expression3
_ -> default_expression
end
A very common use case is to pattern match on the results of potentially error-prone operations:
case foo() do
{:ok, res} -> do_something_with_result(res)
{:error, err} -> handle_error(err)
end
So far, so good. Now, we come to a very common daily work scenario. Imagine that we have one such operation (e.g. external API call, IO or DB operation, etc), and we want to perform a second such operation, but only if the first one were successful. How do we do that?
Recall that conditionals in Elixir are functions too. They can be piped into, or chained in the expression part of other conditionals. This allows us to to solve our problem above using chained conditionals:
case foo() do
{:ok, res} ->
case bar(res) do
{:ok, res2} -> do_something_with_result(res2)
{:error, err} -> handle_error(err)
end
{:error, err} -> handle_error(err)
end
Even with only two such calls, the level of complexity rose drastically. Add just one more such call, and the code becomes unreadable:
case foo() do
{:ok, res} ->
case bar(res) do
{:ok, res2} ->
case baz(res2) do
{:ok, res3} -> do_something_with_result(res3)
{:error, err} -> handle_error(err)
end
{:error, err} -> handle_error(err)
end
{:error, err} -> handle_error(err)
end
with to the rescue #
This is where the with
operator gets really handy. In its basic form, it resembles our chained case
above, but in a way, also functions like a pipeline operator. Check this out:
with {:ok, res} <- foo(),
{:ok, re2} <- bar(res)
{:ok, re3} <- baz(re2) do
do_something_with_result(res3)
end
This shall be interpreted as, “do all the comma separated operations in sequence, and if the previous one has matched, execute the next one. Finally, run the code inside the do/end
block”. This looks much more succinct and readable than its version before, but it has another big advantage too. It allows the programmer to focus on the happy-end business scenarios first. Some of you might have been wondering what would happen, if any of the comma-separated operations returns and {:error, err}
tuple instead. The answer is, the first non-matching expression will be returned. In simple terms, if we don’t care about the outcome of non-ok results, we might as well leave the happy path and leave it to the caller to take care of the final result.
If you have worked with Phoenix, you might recall that this is exactly how its fallback actions work. In our controller actions, we take care of the happy path, and if an error occurs, Phoenix will pattern-match one of our fallback actions to take care of it instead:
defmodule MyController do
use Phoenix.Controller
action_fallback MyFallbackController
def show(conn, %{"id" => id}, current_user) do
with {:ok, post} <- Blog.fetch_post(id),
:ok <- Authorizer.authorize(current_user, :view, post) do
render(conn, "show.json", post: post)
end
end
end
Fallback controller example from the official Phoenix docs
with/else #
If we want to take care of side effects ourselves, with
offers an expanded version:
with {:ok, res} <- foo(),
{:ok, res2} <- bar(res) do
do_something_with_res(res2)
else
{:error, {:some_error, err}} -> handle_some_error(err)
{:error, {:some_other_error, err}} -> handle_some__other_error(err)
default -> handle_something_completely_unexpected(default)
end
NOTE: Keep in mind that while the simple with form won’t throw an error when no match occurs, when using else you have to exhaustively match all cases.
When not to use with #
Using a single pattern-matching clause with else
:
This will make the code more difficult to read than you need it to be. The code below:
with {:ok, res} <- foo() do
do_something_with_res(res)
else
{:error, {:some_error, err}} -> handle_some_error(err)
end
Can easily be replaced with a more readable case
block:
case foo() do
{:ok, res} -> do_something_with_res(res)
{:error, {:some_error, err}} -> handle_some_error(err)
end
Related Reading #
Have something to say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
Elixir Is Not Ruby. Elixir Is Erlang
Elixir is not Ruby. The familiar syntax has definitely helped the language win the hearts of the broader developer community. Yet, under the hood, Elixir is all about Erlang.
Between Go and Elixir
Reason wanted me to make a choice, and I am so glad I didn’t. Because the more I kept delving into both Elixir and Go, the more I found out how complementary the two can be to one another.