Skip to content
with — Chained Pattern Matching
step 1/7

Reading — step 1 of 7

Learn

~2 min readETS, Applications, with

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 inside with:
    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 case or pattern-match destructuring.
  • Don't reach for with for 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…