Elixir by Example

with

Edit any cell and run it in your browser Download source

Many Elixir functions return tagged tuples: {:ok, value} on success and {:error, reason} on failure. with chains several such steps and stops at the first one that doesn't match.

defmodule Signup do
  def parse_age(input) do
    case Integer.parse(input) do
      {age, ""} -> {:ok, age}
      _ -> {:error, :not_a_number}
    end
  end

  def check_adult(age) when age >= 18, do: {:ok, age}
  def check_adult(_age), do: {:error, :too_young}

  def check_email(email) do
    if String.contains?(email, "@"), do: {:ok, email}, else: {:error, :bad_email}
  end

  # Each `pattern <- expression` clause must match to
  # continue. The `do` block runs only if all match.
  def run(params) do
    with {:ok, age} <- parse_age(params["age"]),
         {:ok, age} <- check_adult(age),
         {:ok, email} <- check_email(params["email"]) do
      {:ok, %{age: age, email: email}}
    end
  end
end

On the happy path, every step matches.

IO.inspect(Signup.run(%{"age" => "30", "email" => "ada@example.com"}))
{:ok, %{age: 30, email: "ada@example.com"}}

Without an else, the first non-matching value is returned as-is, so errors pass straight through.

IO.inspect(Signup.run(%{"age" => "abc", "email" => "ada@example.com"}))
IO.inspect(Signup.run(%{"age" => "12", "email" => "ada@example.com"}))
IO.inspect(Signup.run(%{"age" => "30", "email" => "nope"}))
{:error, :not_a_number}
{:error, :too_young}
{:error, :bad_email}

else lets you translate failures, for example into messages for a user. Its clauses match the value that broke the chain.

explain = fn params ->
  with {:ok, age} <- Signup.parse_age(params["age"]),
       {:ok, _} <- Signup.check_adult(age) do
    "welcome"
  else
    {:error, :not_a_number} -> "age must be a number"
    {:error, :too_young} -> "come back when you're 18"
  end
end

IO.puts(explain.(%{"age" => "40"}))
IO.puts(explain.(%{"age" => "x"}))
IO.puts(explain.(%{"age" => "9"}))
welcome
age must be a number
come back when you're 18

Plain = matches work inside with too, for intermediate values that can't fail.

result =
  with {:ok, n} <- {:ok, 21},
       doubled = n * 2 do
    doubled
  end

IO.inspect(result, label: "plain match")
plain match: 42

Pitfall: a long else that matches every possible error from every step couples the caller to all of them. It's usually better to make each step return a descriptive {:error, reason} and let it pass through untouched.

Pitfall: if no else clause matches, with raises WithClauseError. Once you add else, cover every failure shape the steps can return.