Reading — step 1 of 7
Learn
with is Elixir's secret weapon for sequenced operations where any step might fail. Replaces nested case and pyramids of doom.
The problem
def create_user(params) do
case validate(params) do
{:ok, valid} ->
case check_not_taken(valid.email) do
:ok ->
case insert_user(valid) do
{:ok, user} -> {:ok, user}
{:error, _} = err -> err
end
{:error, _} = err -> err
end
{:error, _} = err -> err
end
end
Nested cases for sequential dependent operations. Common, ugly, error-prone.
The solution: with
def create_user(params) do
with {:ok, valid} <- validate(params),
:ok <- check_not_taken(valid.email),
{:ok, user} <- insert_user(valid) do
{:ok, user}
end
end
Reads top-to-bottom: "with this matched, then this matched, then this matched, do that."
If any pattern doesn't match, the WHOLE with expression returns the unmatched value. So validate/1 returning {:error, :missing_email} makes the whole thing return {:error, :missing_email} — short-circuits.
else clause for transformation
with {:ok, valid} <- validate(params),
{:ok, user} <- insert_user(valid) do
{:ok, user}
else
{:error, %Ecto.Changeset{}} = err -> err
{:error, :db_timeout} -> {:error, :service_unavailable}
other -> {:error, {:unexpected, other}}
end
The else block transforms unmatched patterns. Like a catch-all case for the failure modes.
When to use with vs. case vs. pipelines
- Pipeline
|>— for happy-path transformations on a value - case — for branching on shapes (one decision)
- with — for chained operations that depend on each other AND can fail
Real-world examples
Phoenix controller action:
def create(conn, %{"user" => params}) do
with {:ok, params} <- validate_params(params),
{:ok, user} <- Accounts.create_user(params),
{:ok, _email} <- Mailer.send_welcome(user) do
json(conn, %{user: user})
else
{:error, %Ecto.Changeset{} = cs} ->
conn |> put_status(422) |> json(%{errors: format_errors(cs)})
{:error, :email_taken} ->
conn |> put_status(409) |> json(%{error: "email taken"})
end
end
Clean, linear, all error cases handled.
Database transaction:
Result = Repo.transaction(fn ->
with {:ok, user} <- create_user(params),
{:ok, profile} <- create_profile(user),
{:ok, settings} <- create_settings(user) do
{:ok, user}
else
err -> Repo.rollback(err)
end
end)
Tips and gotchas
<-for matching,=for plain assignment insidewith:with {:ok, x} <- fetch(), y = x * 2, # plain assignment, can't fail {:ok, z} <- process(y) do {:ok, z} end- Order matters — earlier expressions can be referenced by later ones.
- Single match — if the only step is one match, just use
caseor pattern-match destructuring. - Don't reach for
withfor one operation — overkill.
Common mistakes
- Using
=when you mean<-—=will raise on no-match instead of short-circuiting. - Forgetting else — without
else, the unmatched value is returned as-is. Sometimes desired, sometimes not. - Putting too much in one with — if you have 6+ steps, consider extracting into helper functions.
- Mixing tagged and untagged returns — keep all the matched expressions returning consistent shapes ({:ok, _}/{:error, _}).
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…