Chain failable operations cleanly with `with`

almirsarajcic

almirsarajcic

1 hour ago

0 comments

Nested case statements for operations that can fail look neat at first — until you need three of them. with lets you sequence failable operations and handle every failure path in one place, without the pyramid.

# ❌ Nested case — each success digs one level deeper
def log_in(params) do
  case fetch_user(params["email"]) do
    {:ok, user} ->
      case verify_password(user, params["password"]) do
        {:ok, user} ->
          case create_session(user) do
            {:ok, session} -> {:ok, session}
            {:error, reason} -> {:error, reason}
          end
        {:error, reason} -> {:error, reason}
      end
    {:error, reason} -> {:error, reason}
  end
end

# ✅ with — flat, reads top to bottom, one place to handle errors
def log_in(params) do
  with {:ok, user}    <- fetch_user(params["email"]),
       {:ok, user}    <- verify_password(user, params["password"]),
       {:ok, session} <- create_session(user) do
    {:ok, session}
  end
end

Each <- arrow pattern-matches the result of the expression on the right. If every clause matches, the do block runs with all bindings in scope. If any clause doesn’t match, with short-circuits and returns that non-matching value directly — no extra wrapping.

The flat version isn’t just shorter. The intent is immediately readable: fetch the user, verify the password, create a session. Failure propagates automatically.

# Add an else clause when you need to handle specific errors differently
def log_in(params) do
  with {:ok, user}    <- fetch_user(params["email"]),
       {:ok, user}    <- verify_password(user, params["password"]),
       {:ok, session} <- create_session(user) do
    {:ok, session}
  else
    {:error, :not_found}         -> {:error, "No account with that email."}
    {:error, :invalid_password}  -> {:error, "Incorrect password."}
    {:error, :too_many_sessions} -> {:error, "Session limit reached."}
  end
end

Without else, any non-matching value falls through as-is. With else, you get pattern-matched error handling in one consolidated block — the same way case works, but for the failure paths of the entire chain.

A real-world registration flow shows why this matters:

defmodule MyApp.Accounts do
  alias MyApp.Repo

  @spec register(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()}
  def register(params) do
    with {:ok, params} <- validate_registration(params),
         {:ok, user}   <- insert_user(params),
         {:ok, _email} <- send_welcome_email(user) do
      {:ok, user}
    else
      {:error, reason} -> {:error, reason}
    end
  end
end

Three sequential operations — validate, insert, email — each potentially failing. The else clause catches any {:error, reason} from any step and passes it through. If you need to distinguish between them (say, a failed mailer shouldn’t block registration), you can pattern-match on the specific reason in else.

When to use with vs case:

  • Use with when you have two or more sequential operations that each return {:ok, _} or {:error, _} and the happy path is what you primarily care about.
  • Use case when branching on a single value, especially when all branches are equally important.

with in the Elixir docs

Comments (0)

Sign in with GitHub to join the discussion