Better Control Flow Using The "with" Macro

Screen shot of Elixir code demonstrating using the "with" macro.

Since Elixir 1.2, Elixir has had the with macro to assist with more expressive control flow. Instead of deeply nested case and if/else blocks, you can have one single with block to express the same logic but in a more elegant and readable way. I’ll explore how you can utilize with to improve your code.

with Basics

with works by taking a list of clauses to be matched in order. If all clauses match, then the code in the do block is executed. When a clause doesn’t match in the list, execution stops and the non-matched value is returned.

iex> with {int, _} <- Integer.parse("10") do
...>   10 * int
...> end
100

iex> with {int, _} <- Integer.parse("foo") do
...>   10 * int
...> end
:error

iex> with {int, _} <- Integer.parse("9"),
...>      true <- Integer.is_even(int) do
...>   10 * int
...> end
false

You can even use guards on your match clauses.

iex> with {int, _} when int != 0 <- Integer.parse("9"),
...>   99 / int
...> end
11.0

Additionally, you can capture the unmatched value by using an else block and match on possible error values.

iex> with {int, _} <- Integer.parse("9"),
...>      true <- Integer.is_even(int) do
...>   10 * int
...> else
...>   :error -> {:error, :not_an_int} # error for bad parsing
...>   false -> {:error, :not_even} # error for odd number
...> end
{:error, :not_even}

Alternatively, you can ignore all error values and return the same error for all error cases.

iex> with {int, _} <- Integer.parse("9"),
...>      true <- Integer.is_even(int) do
...>   10 * int
...> else
...>   _ -> {:error, :invalid_value}
...> end
{:error, :invalid_value}

You can even assign values in your match clauses. Be aware that you can get a MatchError if you provide an invalid assignment.

iex> with {int, _} <- Integer.parse("9"),
...>      squared = int * int,
...>      false <- Integer.is_even(int) do
...>   squared + 1
...> end
82

iex> with 1 = "1", do: :ok
** (MatchError) no match of right hand side value: "1"

Be sure to read the full documentation for more information.

A Practical Example

Let’s look at an example that you’ve probably encountered where you need to update an existing record in a controller and send out some sort of notification.

def update(conn, params \\ %{}) do
  case Documents.get(params["id"]) do
    {:ok, document} ->
      case Documents.update(document, params) do
        {:ok, document} ->
          Notifications.push_document_updated(document)
          json(conn, document)

        {:error, %Ecto.Changeset{} = changeset} ->
          render(conn, ErrorView, :"400", changeset)
      end
    {:error, :not_found} ->
      render(conn, ErrorView, :"404")
  end
end

We can easily see that we have nested case statements just to get to the success path that we want. Rewriting this using with for the happy path will make this code much more succinct.

def update(conn, params \\ %{}) do
  with {:ok, document} <- Documents.get(params["id"]),
       {:ok, updated_document} <- Documents.update(document, params) do
    Notifications.push_document_updated(updated_document)
    json(conn, document)
  else
    {:error, :not_found} ->
      render(conn, ErrorView, :"404")

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, ErrorView, :"400", changeset)
  end
end

After rewriting the code, we can clearly see what the happy path is and what our expected errors are.

Taking it a Step Further

Often you’ll find yourself matching on terms that have similar error values, making it more complex to handle your error cases. One trick I like to use is pairing values with an atom to uniquely identify the check like {:my_atom, "expected_value"}.

def update(conn, params \\ %{}) do
  user = conn.assigns.user
  with {:ok, document} <- Document.get(params["id"]),
       {:can_view?, true} <- {:can_view?, Authorizer.can_view?(document, user)},
       {:can_edit?, true} <- {:can_edit?, Authorizer.can_edit?(document, user)}
       {:ok, updated_document} <- Documents.update(document, params) do
    Notifications.push_document_updated(updated_document)
    json(conn, document)
  else
    {:error, :not_found} ->
      render(conn, ErrorView, :"404")

    {:can_view?, false} ->
      render(conn, ErrorView, :"404")

    {:can_edit?, false} ->
      render(conn, ErrorView, :"403")

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, ErrorView, :"400", changeset)
  end
end

Adding a unique atom made it very easy to identify the specific error and return the appropriate result back.

Wrapping Up

with helps us write cleaner, more expressive code without sacrificing on error handling or readability. Take advantage of the macro whenever you deal with complex flows of logic. Don’t forget to take a look at the full documentation for with.

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box