Elixir: Errors & Failure Idioms

Elixir distinguishes two failure worlds: expected failures, returned as values, and unexpected failures, raised as exceptions and handled by restarting the process. Choosing the right one per situation is what makes BEAM systems robust without try/except soup.

Expected vs Unexpected Failures

SituationIdiomExample
Caller can handle it (missing file, bad input, not found)return {:ok, v} / {:error, reason}File.read/1
Program bug or impossible stateraise; let the process diefunction clause error
Resource that must be releasedtry ... aftersockets, files, DB connections
Validation of user inputchangesets / tagged tuplesEcto changesets

The guiding principle is let it crash: code should either succeed or fail loudly, because a supervisor will restart it in a known-good state. Defensive rescues everywhere hide bugs instead of surfacing them.

raise, rescue and after

# Raising: for bugs and invariant violations.
defmodule Withdraw do
  def run(balance, amount) when amount > balance do
    raise ArgumentError, "insufficient funds: #{balance} < #{amount}"
  end
  def run(balance, amount), do: {:ok, balance - amount}
end

# Rescuing: converts an exception into a value — use sparingly, at
# the boundary where you can actually recover.
try do
  risky_operation()
rescue
  e in ArgumentError -> {:error, e.message}   # match by type
  e in RuntimeError  -> {:error, {:runtime, e.message}}
after
  Logger.debug("risky_operation finished")    # always runs, like finally
end

after is the valuable half of try: release resources even when an exception flies through. If you only need cleanup, omit rescue entirely.

Custom Exceptions

Exceptions are structs under the hood, so your own error types are just modules with defexception — they carry structured data instead of a bare string.

defmodule CheckoutError do
  defexception [:code, :detail]

  # message/1 renders the error for logs and human readers.
  @impl true
  def message(%__MODULE__{code: code, detail: detail}) do
    "checkout failed (#{code}): #{detail}"
  end
end

raise CheckoutError, code: :card_declined, detail: "issuer refused"

with and Error Pipelines

with chains match expectations: every <- arrow must match or control jumps to else. It reads as the happy path first, with all failure shapes collected at the bottom — the idiomatic replacement for nested case.

Two paths from an operation: an {:ok, _} tuple continues the pipeline while {:error, _} or a raised exception follows the failure path
Fig. 1 — Expected failures flow back as {:error, reason} values; unexpected ones crash the process and become supervisor work.
defmodule Import do
  def run(path) do
    with {:ok, raw} <- File.read(path),
         {:ok, data} <- parse(raw),
         {:ok, count} <- save(data) do
      {:ok, count}
    else
      # Each clause re-matches whatever failed, normalizing the result.
      {:error, :enoent} -> {:error, :file_missing}
      {:error, %JSON.DecodeError{}} -> {:error, :bad_json}
      {:error, reason} -> {:error, reason}
    end
  end

  defp parse(raw) do
    case JSON.decode(raw) do
      {:ok, map} -> {:ok, map}
      # JSON.decode returns {:error, exception} instead of raising,
      # so the failure stays a value the `with` chain can normalize.
      {:error, exception} -> {:error, exception}
    end
  end

  defp save(_data), do: {:ok, 42}
end

Errors Across Processes

Failure semantics change at process boundaries. If a process linked to yours dies, you receive an :EXIT message (or die with it, depending on trap flags); a monitor gives you a :DOWN message without killing anything. A GenServer.call/3 that hits a dead server exits with a wrapped reason instead of returning an error tuple. These mechanics are the subject of The Process Model — for now, remember: exceptions do not cross processes; signals do.

Practice

  1. Write Withdraw.run/2, then a test for both the {:ok, _} and the raised path.
  2. Convert a three-level nested case into with ... else and count how much flatter the happy path reads.
  3. Define a custom exception with two fields and raise it from a function clause that guards on invalid input.

Next: Strings, Binaries & Sigils