We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Chain failable operations cleanly with `with`
almirsarajcic
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
withwhen you have two or more sequential operations that each return{:ok, _}or{:error, _}and the happy path is what you primarily care about. - Use
casewhen branching on a single value, especially when all branches are equally important.
copied to clipboard