Elixir: Errors & Failure Idioms
Expected vs Unexpected Failures
| Situation | Idiom | Example |
|---|---|---|
| Caller can handle it (missing file, bad input, not found) | return {:ok, v} / {:error, reason} | File.read/1 |
| Program bug or impossible state | raise; let the process die | function clause error |
| Resource that must be released | try ... after | sockets, files, DB connections |
| Validation of user input | changesets / tagged tuples | Ecto 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.
{: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
- Write
Withdraw.run/2, then a test for both the{:ok, _}and the raised path. - Convert a three-level nested
caseintowith ... elseand count how much flatter the happy path reads. - Define a custom exception with two fields and raise it from a function clause that guards on invalid input.