Elixir Tip: Case vs. With
By: Preslav Rachev
Tags: Phoenix Framework, Programming Languages, Programming, Elixir

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: