Skip to main content
Preslav Rachev

Image Credits: Louis Reed / Unsplash

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

Elixir: Thoughts on the `with` Statement – Repeatable Systems
Elixir has a some great syntactic sugar. A nice feature that was introducedback in Elixir 1.2 is the with statement w...

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

·3 mins

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

·10 mins

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.