6 min read
On this page

With Statements

with is the Elixir feature you do not realize you needed until you have written your fifth nested case block. It chains together a sequence of pattern matches, runs the body when they all succeed, and returns the first non-matching value when one fails. Once it clicks, you reach for it constantly — controllers, command handlers, validation pipelines, anything that does several "this should succeed and then this and then this" operations.

The Shape of with

with {:ok, user} <- fetch_user(id),
     {:ok, account} <- fetch_account(user),
     {:ok, balance} <- check_balance(account, amount),
     {:ok, txn} <- create_transaction(account, amount) do
  {:ok, txn}
end

Each clause uses <- to pattern match. If every match succeeds, the do block runs. If any clause fails to match, the value that failed to match becomes the value of the entire with.

Compare that to nested case:

case fetch_user(id) do
  {:ok, user} ->
    case fetch_account(user) do
      {:ok, account} ->
        case check_balance(account, amount) do
          {:ok, balance} ->
            case create_transaction(account, amount) do
              {:ok, txn} -> {:ok, txn}
              {:error, _} = err -> err
            end
          {:error, _} = err -> err
        end
      {:error, _} = err -> err
    end
  {:error, _} = err -> err
end

Same logic, four levels of nesting, and the actual happy path is buried under boilerplate. The with version reads top to bottom, the failure cases handle themselves, and adding a new step is one more line, not another level of indentation.

How <- Differs From =

It looks like <- is just a fancier =, but they behave differently inside with.

= is a hard match. If it fails, you get a MatchError and the whole expression crashes.

<- inside with is a soft match. If it fails, the non-matching value is returned from the with and the body never runs.

with {:ok, n} <- {:error, :nope} do
  n * 2
end
# returns {:error, :nope}, does not crash

You can mix <- and = clauses. Use <- for matches that might fail, = for transformations you expect to always succeed.

with {:ok, raw} <- File.read(path),
     parsed = String.trim(raw),
     {:ok, json} <- Jason.decode(parsed) do
  {:ok, json}
end

If String.trim/1 somehow returned something pathological, that would still crash with a MatchError because it is a =, not a <-. That is usually what you want — bugs should be loud, business logic failures should be quiet.

The else Clause

Sometimes the failing value is not the value you want to return. You want to translate it. That is what else is for.

with {:ok, user} <- fetch_user(id),
     {:ok, account} <- fetch_account(user),
     {:ok, balance} <- check_balance(account, amount) do
  {:ok, balance}
else
  {:error, :not_found} ->
    {:error, "no such user or account"}

  {:error, :insufficient_funds} ->
    {:error, "balance too low"}

  {:error, reason} ->
    {:error, "unexpected: #{inspect(reason)}"}
end

else runs when any <- clause fails to match its pattern. The non-matching value is what gets matched against the else clauses. If no else clause matches the failing value, you get a WithClauseError. So either be exhaustive or include a wildcard _ -> ....

Use else sparingly. If every with you write has an else, that is a sign your functions are returning inconsistent error shapes and you would be better off normalizing them. Phoenix controllers in particular often do not need an else — the action layer takes raw {:error, changeset} and renders it correctly.

A Real Example: A Controller Action

This is close to what you would write in a Phoenix app:

def create(conn, %{"transfer" => params}) do
  with {:ok, attrs} <- Transfers.normalize_params(params),
       {:ok, source} <- Accounts.get_account(attrs.source_id),
       {:ok, target} <- Accounts.get_account(attrs.target_id),
       :ok <- Accounts.authorize(conn.assigns.current_user, source),
       {:ok, transfer} <- Transfers.execute(source, target, attrs.amount) do
    conn
    |> put_status(:created)
    |> render("show.json", transfer: transfer)
  else
    {:error, :unauthorized} ->
      conn |> put_status(:forbidden) |> json(%{error: "forbidden"})

    {:error, %Ecto.Changeset{} = changeset} ->
      conn |> put_status(:unprocessable_entity) |> render("error.json", changeset: changeset)

    {:error, reason} ->
      conn |> put_status(:bad_request) |> json(%{error: to_string(reason)})
  end
end

This is a fairly typical structure. Each line is a step that might fail with a tagged tuple. The else translates failures into HTTP responses. The happy path renders the result. There is no nesting. Bleacher Report's API endpoints are full of this pattern — sometimes ten or more <- clauses in a single with, with a small else that catches the half-dozen distinct failure shapes.

with Without {:ok, _}

with is not married to {:ok, _} and {:error, _}. It works with any pattern.

with %User{role: :admin} <- get_current_user(conn),
     [_ | _] = pending <- list_pending_approvals() do
  pending
end

Here with runs the body only if the user is an admin and there is at least one pending item. If the user is not an admin, the user struct flows out as the value. If the list is empty, the empty list flows out. You probably want an else here unless you are okay returning a User or [] from this expression.

The convention of using {:ok, _} and {:error, _} is a community agreement, not a language rule. Stick to it for things that look like operations that can succeed or fail, but feel free to use other patterns where they fit.

When with Shines

A few situations where with is clearly the right tool:

Validating user input through several stages. Parse the JSON, check required fields, validate types, normalize values, persist. Each step can fail with a different reason.

Chaining database queries that each might return nil. Find the user, find their team, find their permissions, find their billing plan. Any of these can fail with nil (or {:error, :not_found}), and with lets you exit the chain at any point.

Running side effects that might fail. Acquire a lock, perform the work, release the lock. If acquiring the lock fails, you skip the work. with makes this read top to bottom.

Composing multiple Ecto.Changeset validations. Each step returns {:ok, changeset} or {:error, changeset}, and with runs them in order until one fails.

When with Does Not Fit

with is overkill for two or fewer clauses. A single case is fine for case fetch(id) do, and even one <- clause with is just case with extra ceremony.

with is also wrong when the steps are independent — when you want to run all of them and collect their results, not short-circuit on the first failure. For that, you collect the results explicitly:

results = [
  validate_email(params),
  validate_password(params),
  validate_age(params)
]

case Enum.split_with(results, &match?({:ok, _}, &1)) do
  {oks, []} -> {:ok, Enum.map(oks, fn {:ok, v} -> v end)}
  {_, errors} -> {:error, errors}
end

with short-circuits. If you want all-or-nothing validation, you usually want all the errors at once, not just the first one.

Comparing with case Nesting

The contrast is worth seeing in detail:

# case nesting
def create_post(user_id, params) do
  case Accounts.get_user(user_id) do
    {:ok, user} ->
      case Posts.changeset(%Post{}, params) do
        %{valid?: true} = changeset ->
          case Repo.insert(changeset) do
            {:ok, post} -> {:ok, post}
            {:error, _} = err -> err
          end
        invalid -> {:error, invalid}
      end
    {:error, _} = err -> err
  end
end
# with
def create_post(user_id, params) do
  with {:ok, user} <- Accounts.get_user(user_id),
       %{valid?: true} = changeset <- Posts.changeset(%Post{}, params),
       {:ok, post} <- Repo.insert(changeset) do
    {:ok, post}
  end
end

The with version is shorter, the steps are easier to reorder or extend, and the failure handling is implicit but consistent. There is a real argument that the case version is more explicit about how each failure flows out — but in a codebase with hundreds of these, the with form scales better and is what you will find in nearly every Phoenix project.

Common Pitfalls

Confusing <- with =. Inside with, both are legal but they behave differently. <- is the soft match that drives the short-circuit. = is a hard match that crashes on failure. Mix them up and you either crash on what should be a flow or silently flow on what should be a crash.

Forgetting that else matches the failing value, not a fixed pattern. else clauses run pattern matching on whatever value caused the with to short-circuit. If your <- clauses can fail with several different shapes, your else needs to handle them all.

Using with for a single clause. with {:ok, x} <- foo() do ... is just case foo() do {:ok, x} -> ...; other -> other end. Save with for two or more steps.

Returning inconsistent error shapes from your steps. If step_a/1 returns {:error, :not_found} and step_b/1 returns :error and step_c/1 returns nil, the else block becomes a mess of pattern matches. Normalize your error shapes upstream.

Treating with as "early return." It is not. with only short-circuits when a <- clause fails to match. You cannot break out of a with body once it has started executing.

Key Takeaways

  • with chains pattern matches with <-, runs the body when they all succeed, and returns the first non-matching value when one fails.
  • <- is a soft match. = is a hard match. Both are legal inside with and serve different purposes.
  • The else clause translates failure values into different return values. Use it only when your steps return shapes that need translation.
  • with shines when you have three or more sequential operations that each might fail with {:ok, _} / {:error, _} shapes.
  • with is the natural successor to |> when error handling enters a pipeline.
  • Reach for with instead of nested case, but do not force it on logic that does not need short-circuiting.