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
.