- Add ## Contents and ## Decision Tree to all 10 existing pattern files - Fix embed_as/1 semantics inversion in types.md (:self → :dump) - Fix fabricated __meta__.changes reference in changesets.md - Fix default primary key type (:integer → :id) in schemas.md - Combine @impl subsections into single "Minimal Callback Annotation"
49 KiB
Error Handling Patterns in Elixir Core
Patterns extracted from Elixir's standard library source code.
Contents
- The
withMacro — Normalized Error Clauses - Real-World
with— Multi-Step Fallible Operations - Another
with— Error Info Extraction {:ok, value}/:errorConvention (Map.fetch)- Bang Functions: Raise on Error (
fetch!vsfetch) - Exception Structure:
defexceptionFields - Custom
exception/1Callback for Ergonomic Raising raiseMacro Internals: Compile-Time Type Resolution- Error Normalization: Erlang → Elixir Exception Translation
blame/2Callback: Enriching Exceptions After the Fact- Guards for Type Dispatch in Error Handling
- The
:error/{:error, reason}Convention Split reduce_while— Early Exit Without Exceptions- Three-Tier Error Strategy in Map Operations
1. The with Macro — Normalized Error Clauses
Source: lib/elixir/lib/kernel/special_forms.ex#L1600 (docs + definition)
What it does: The with macro chains pattern-matched steps where each <- clause returns a normalized error shape. When a step fails to match, the non-matched value falls through (or hits else).
# From the with docs (special_forms.ex line 1689-1707):
# GOOD — each helper returns a normalized {:error, reason} shape
with :ok <- validate_extension(path),
:ok <- validate_exists(path) do
backup_path = path <> ".backup"
File.cp!(path, backup_path)
{:ok, backup_path}
end
defp validate_extension(path) do
if Path.extname(path) == ".ex", do: :ok, else: {:error, :invalid_extension}
end
defp validate_exists(path) do
if File.exists?(path), do: :ok, else: {:error, :missing_file}
end
Why: The docs explicitly warn (line 1672) against reconstructing return types in else blocks. Instead, normalize each step so the unmatched value is already meaningful. This makes else optional — the error value itself is the result.
Anti-pattern: Matching raw return values in else and reconstructing meaning:
# BAD — from the docs (line 1660-1678)
with ".ex" <- Path.extname(path),
true <- File.exists?(path) do
{:ok, path}
else
binary when is_binary(binary) ->
{:error, :invalid_extension}
false ->
{:error, :missing_file}
end
When to Use
Triggers:
- You have 2+ sequential steps that each return a value you need to pattern-match before continuing
- Each step can fail independently, and you want the error to propagate cleanly
- The caller cares about which step failed (different error reasons)
Example — before:
def create_user(params) do
case validate_email(params) do
:ok ->
case validate_password(params) do
:ok ->
case Repo.insert(User.changeset(params)) do
{:ok, user} -> {:ok, user}
{:error, changeset} -> {:error, changeset}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
end
Example — after:
def create_user(params) do
with :ok <- validate_email(params),
:ok <- validate_password(params),
{:ok, user} <- Repo.insert(User.changeset(params)) do
{:ok, user}
end
end
When NOT to Use
Don't use this when:
- You have only one fallible step (just use
case) - The steps are independent and don't feed into each other (use separate validations)
- You need to transform the error at each step differently
Over-application example:
# Overkill — single step doesn't need `with`
with {:ok, user} <- Repo.get_user(id) do
{:ok, user}
end
Better alternative:
Repo.get_user(id)
Why: with adds cognitive overhead. When there's only one <- clause, it's just a more verbose case. Reserve with for genuine multi-step chains where the flattening of nested cases provides real clarity.
2. Real-World with — Multi-Step Fallible Operations
Source: lib/elixir/lib/exception.ex#L251 (blame_mfa/4)
What it does: Uses with to chain 5+ fallible steps where any failure should produce :error. Each step's pattern is an exact match.
defp blame_mfa(module, function, arity, call_args) do
with [_ | _] = path <- :code.which(module),
{:ok, {_, [debug_info: debug_info]}} <- :beam_lib.chunks(path, [:debug_info]),
{:debug_info_v1, backend, data} <- debug_info,
{:ok, %{definitions: defs}} <- backend.debug_info(:elixir_v1, module, data, []),
{_, kind, _, clauses} <- List.keyfind(defs, {function, arity}, 0) do
# ... process clauses ...
{:ok, kind, clauses}
else
_ -> :error
end
end
Why: Five sequential operations where each can fail differently. Without with, this would be 5 nested case statements. The else _ -> :error collapses all failure modes into one — appropriate here because callers only care success vs failure, not which step failed.
Anti-pattern: Deeply nested case statements:
# BAD — pyramid of doom
case :code.which(module) do
[_ | _] = path ->
case :beam_lib.chunks(path, [:debug_info]) do
{:ok, {_, [debug_info: debug_info]}} ->
case debug_info do
{:debug_info_v1, backend, data} ->
# ... and so on
When to Use
Triggers:
- You have 3+ steps that depend on previous results and any can fail
- The caller only cares about success vs. failure (not which step failed)
- The failure values from different steps have incompatible shapes (bare atoms, tuples,
nil,false)
Example — before:
def load_config(path) do
case File.read(path) do
{:ok, contents} ->
case Jason.decode(contents) do
{:ok, json} ->
case Map.fetch(json, "database") do
{:ok, db_config} ->
case validate_db_config(db_config) do
:ok -> {:ok, db_config}
error -> error
end
:error -> {:error, :missing_database_key}
end
{:error, _} -> {:error, :invalid_json}
end
{:error, reason} -> {:error, reason}
end
end
Example — after:
def load_config(path) do
with {:ok, contents} <- File.read(path),
{:ok, json} <- Jason.decode(contents),
{:ok, db_config} <- Map.fetch(json, "database"),
:ok <- validate_db_config(db_config) do
{:ok, db_config}
else
_ -> {:error, :config_load_failed}
end
end
When NOT to Use
Don't use this when:
- The caller needs to know exactly which step failed with a specific reason
- You need different recovery strategies for different failure points
else _ -> :errorwould hide important diagnostic information in production code
Over-application example:
# Hides the actual failure from callers who need it
def process_payment(order) do
with {:ok, card} <- fetch_card(order.user_id),
{:ok, charge} <- charge_card(card, order.total),
{:ok, receipt} <- create_receipt(charge) do
{:ok, receipt}
else
_ -> {:error, :payment_failed}
end
end
Better alternative:
# Let each error propagate with its reason
def process_payment(order) do
with {:ok, card} <- fetch_card(order.user_id),
{:ok, charge} <- charge_card(card, order.total),
{:ok, receipt} <- create_receipt(charge) do
{:ok, receipt}
end
# Each step returns {:error, :card_not_found}, {:error, :declined}, etc.
Why: When errors require different handling (retry payment vs notify user vs alert ops), collapsing them into _ -> :error removes actionable information. Use the catch-all only when the caller genuinely treats all failures the same.
3. Another with — Error Info Extraction
Source: lib/elixir/lib/exception.ex#L2695 (error_info/3 in ErlangError)
What it does: Chains pattern matching on a stacktrace and error_info map to extract formatted error details.
defp error_info(erl_exception, stacktrace, default_reason) do
with [{module, fun, args_or_arity, opts} | tail] <- stacktrace,
%{} = error_info <- opts[:error_info] do
error_module = Map.get(error_info, :module, module)
error_fun = Map.get(error_info, :function, :format_error)
# ... format the error ...
cond do
map_size(args_errors) > 0 ->
{:ok, reason, IO.iodata_to_binary([":\n\n" | Enum.map(args_errors, &arg_error/1)])}
general = extra[:general] ->
{:ok, reason, ": " <> IO.chardata_to_string(general)}
true ->
:error
end
else
_ -> :error
end
end
Why: The stacktrace might be empty or the opts might not contain :error_info. Using with makes the "happy path" clear while gracefully falling through to :error for any mismatch.
When to Use
Triggers:
- You're extracting nested data from loosely-structured inputs (stacktraces, metadata maps, external API responses)
- The data might not contain what you need at any level of nesting
- A graceful "not available" result is acceptable
Example — before:
def extract_request_id(conn) do
case List.keyfind(conn.req_headers, "x-request-id", 0) do
{_, value} ->
case String.split(value, "-") do
[_prefix, id | _] ->
case Integer.parse(id) do
{num, ""} -> {:ok, num}
_ -> :error
end
_ -> :error
end
nil -> :error
end
end
Example — after:
def extract_request_id(conn) do
with {_, value} <- List.keyfind(conn.req_headers, "x-request-id", 0),
[_prefix, id | _] <- String.split(value, "-"),
{num, ""} <- Integer.parse(id) do
{:ok, num}
else
_ -> :error
end
end
When NOT to Use
Don't use this when:
- Each extraction step needs specific fallback behavior (not just
:error) - You want to log or report which level of extraction failed
- The nested structure is well-typed and guaranteed by the type system
Over-application example:
# When you need different behavior at each failure point
def extract_user_preference(user, key) do
with %{preferences: prefs} <- user,
{:ok, value} <- Map.fetch(prefs, key) do
{:ok, value}
else
_ -> :error # Was it missing preferences entirely, or just the key?
end
end
Better alternative:
def extract_user_preference(%{preferences: prefs}, key) do
Map.fetch(prefs, key)
end
def extract_user_preference(_user, _key), do: {:error, :no_preferences}
Why: When different failure modes demand different responses (create default preferences vs. ignore missing key), pattern-matching function heads are clearer than with + catch-all.
4. {:ok, value} / :error Convention (Map.fetch)
Source: lib/elixir/lib/map.ex#L290
What it does: Map.fetch/2 returns {:ok, value} on success and bare :error on failure. No reason atom, because the failure mode is obvious (key not found).
@spec fetch(map, key) :: {:ok, value} | :error
def fetch(map, key), do: :maps.find(key, map)
Why: The :error atom alone (not {:error, reason}) is idiomatic when there's only one possible failure mode. It's lighter to pattern match and compose with with:
with {:ok, width} <- Map.fetch(opts, "width"),
{:ok, height} <- Map.fetch(opts, "height") do
{:ok, width * height}
end
# Returns :error if either key is missing
Anti-pattern: Returning {:error, :not_found} when there's only one failure mode:
# BAD — unnecessary tuple wrapping for a single failure mode
def fetch(map, key) do
case :maps.find(key, map) do
{:ok, value} -> {:ok, value}
:error -> {:error, :not_found} # pointless — what else could it be?
end
end
When to Use
Triggers:
- Your function has exactly one failure mode that's obvious from context
- Callers will compose this with
withor other pattern-matching constructs - The function is a low-level building block called frequently (allocations matter)
Example — before:
def get_env(key) do
case System.get_env(key) do
nil -> {:error, :not_set}
value -> {:ok, value}
end
end
Example — after:
def fetch_env(key) do
case System.get_env(key) do
nil -> :error
value -> {:ok, value}
end
end
When NOT to Use
Don't use this when:
- Multiple distinct failure modes exist (file I/O:
:enoent,:eacces,:eisdir) - The caller needs to present failure reasons to users or log them
- The function crosses a boundary (API, service call) where failures need context
Over-application example:
# Bare :error hides important diagnostic info
def connect(host, port) do
case :gen_tcp.connect(host, port, []) do
{:ok, socket} -> {:ok, socket}
{:error, _reason} -> :error # Was it timeout? Refused? DNS failure?
end
end
Better alternative:
def connect(host, port) do
case :gen_tcp.connect(host, port, []) do
{:ok, socket} -> {:ok, socket}
{:error, reason} -> {:error, reason} # Preserve the reason
end
end
Why: Stripping the reason from a multi-failure function destroys information callers need. Use bare :error only when there's genuinely nothing more to say.
5. Bang Functions: Raise on Error (fetch! vs fetch)
Source: lib/elixir/lib/map.ex#L311
What it does: The ! suffix convention means "raises on failure instead of returning an error tuple." The non-bang version is for when the caller wants to handle the error.
# Non-bang: returns {:ok, value} | :error — caller decides
@spec fetch(map, key) :: {:ok, value} | :error
def fetch(map, key), do: :maps.find(key, map)
# Bang: raises KeyError — caller expects success
@spec fetch!(map, key) :: value
def fetch!(map, key), do: :maps.get(key, map)
Why: Two audiences exist:
- Code that needs to handle missing keys gracefully → use
fetch/2 - Code where missing key is a bug (preconditions guarantee it exists) → use
fetch!/2
The bang version communicates "I assert this will succeed; if it doesn't, it's a program error."
Anti-pattern: Always using bang functions and rescuing:
# BAD — using exceptions for control flow
try do
Map.fetch!(map, key)
rescue
KeyError -> default_value
end
# GOOD — use the non-bang version
case Map.fetch(map, key) do
{:ok, value} -> value
:error -> default_value
end
When to Use
Triggers:
- You're writing a library with functions that can fail, and want to provide both error-tuple and raising variants
- The caller has preconditions guaranteeing success (e.g., key was validated upstream)
- You want pipeline-friendly functions that return raw values, not tuples
Example — before:
# Caller must unwrap every time even when failure is impossible
{:ok, user} = Repo.fetch_user(session.user_id)
{:ok, org} = Repo.fetch_org(user.org_id)
render(conn, user: user, org: org)
Example — after:
# Bang versions assert preconditions — crash if invariant is violated
user = Repo.fetch_user!(session.user_id)
org = Repo.fetch_org!(user.org_id)
render(conn, user: user, org: org)
When NOT to Use
Don't use this when:
- Failure is an expected, normal outcome (user input, network calls, file I/O)
- You want to handle the error and continue (show a message, retry, fallback)
- The function is called in a loop where one failure shouldn't crash the process
Over-application example:
# Bang in a loop — one bad record kills the whole batch
def process_all(records) do
Enum.map(records, fn record ->
ExternalAPI.fetch_details!(record.id) # Crashes on any network error
end)
end
Better alternative:
def process_all(records) do
Enum.map(records, fn record ->
case ExternalAPI.fetch_details(record.id) do
{:ok, details} -> {:ok, details}
{:error, reason} -> {:error, record.id, reason}
end
end)
end
Why: Bang functions express "failure here means a bug." If failure is a normal possibility (network hiccup, missing optional data), use the non-bang version and handle the error.
6. Exception Structure: defexception Fields
Source: lib/elixir/lib/exception.ex#L2250 (exception definitions)
What it does: Exceptions carry meaningful fields beyond just :message. The message/1 callback generates a human-readable string from those fields.
# File.Error — stores structured data, formats on demand
defexception [:reason, :path, action: ""]
@impl true
def message(%{action: action, reason: reason, path: path}) do
formatted =
case {action, reason} do
{"remove directory", :eexist} -> "directory is not empty"
_ -> IO.iodata_to_binary(:file.format_error(reason))
end
"could not #{action} #{inspect(path)}: #{formatted}"
end
# KeyError — carries the key AND the term for context
defexception [:key, :term, :message]
# Enum.OutOfBoundsError — carries index + enumerable
defexception [:enumerable, :index, :message]
Why: Fields enable programmatic inspection of errors (pattern matching in rescue, blame callbacks). The message/1 callback is only called when the error needs to be displayed. Storing structured data costs nothing until you actually need the string.
Anti-pattern: Storing only a pre-formatted message string:
# BAD — can't programmatically inspect the error
defexception message: "something went wrong"
raise MyError, "file not found: #{path}"
# Caller can't extract `path` from the exception
When to Use
Triggers:
- You're defining a domain-specific exception that callers might want to inspect programmatically
- The error has structured context (path, key, code, HTTP status) beyond just a message
- Different callers need different representations of the same error (human-readable vs machine-parseable)
Example — before:
defmodule PaymentError do
defexception message: "payment failed"
end
raise PaymentError, "payment failed: card declined for order #123"
# Caller can't extract the order ID or reason programmatically
Example — after:
defmodule PaymentError do
defexception [:reason, :order_id, :amount]
@impl true
def message(%{reason: reason, order_id: id, amount: amount}) do
"payment failed for order ##{id} ($#{amount}): #{reason}"
end
end
raise PaymentError, reason: :declined, order_id: 123, amount: "49.99"
# Callers can rescue and inspect: e.reason, e.order_id
When NOT to Use
Don't use this when:
- The error is truly a one-off with no structured context (simple assertion failure)
- No caller will ever
rescueand inspect fields — it's always a crash - You're wrapping a third-party error where you'd just copy their message anyway
Over-application example:
# Overkill for a simple invariant violation
defmodule InvalidStateError do
defexception [:current_state, :expected_states, :module, :function, :timestamp]
def message(%{current_state: s, expected_states: es, module: m, function: f}) do
"#{m}.#{f} expected state in #{inspect(es)}, got #{inspect(s)}"
end
end
Better alternative:
# Simple message is fine for programmer errors that always crash
raise "expected state in #{inspect(expected)}, got #{inspect(actual)}"
Why: If nobody will ever pattern-match on the exception fields, the boilerplate of defexception with multiple fields adds complexity without value. Use structured exceptions when the caller needs to make decisions based on the error content.
7. Custom exception/1 Callback for Ergonomic Raising
Source: lib/elixir/lib/exception.ex#L2255 (UnicodeConversionError)
What it does: Override exception/1 to accept raw values (not just keyword lists) and build the struct with a meaningful message.
defmodule UnicodeConversionError do
defexception [:encoded, :message]
def exception(opts) do
%UnicodeConversionError{
encoded: Keyword.fetch!(opts, :encoded),
message: "#{Keyword.fetch!(opts, :kind)} #{detail(Keyword.fetch!(opts, :rest))}"
}
end
defp detail(rest) when is_binary(rest) do
"encoding starting at #{inspect(rest)}"
end
defp detail([h | _]) when is_integer(h) do
"code point #{h}"
end
end
Why: The default exception/1 just merges keywords into the struct. Custom callbacks let you compute messages from raw data, validate required fields (with fetch!), and transform inputs. This keeps raise calls clean:
raise UnicodeConversionError, encoded: data, kind: "invalid", rest: <<0xFF>>
When to Use
Triggers:
- The exception message is computed from multiple fields (not stored verbatim)
- Some fields are required and should fail loudly if omitted at raise time
- You want to accept transient data at raise time that doesn't need to be stored on the struct
Example — before:
defmodule RateLimitError do
defexception [:message, :retry_after]
end
# Caller must build the message themselves
raise RateLimitError,
message: "Rate limited. Retry after #{seconds}s (limit: #{limit}/min)",
retry_after: seconds
Example — after:
defmodule RateLimitError do
defexception [:retry_after, :limit, :message]
def exception(opts) do
retry = Keyword.fetch!(opts, :retry_after)
limit = Keyword.fetch!(opts, :limit)
%RateLimitError{
retry_after: retry,
limit: limit,
message: "Rate limited. Retry after #{retry}s (limit: #{limit}/min)"
}
end
end
# Clean raise site
raise RateLimitError, retry_after: 30, limit: 100
When NOT to Use
Don't use this when:
- The default keyword-merge behavior is sufficient (simple exceptions with optional fields)
- The exception only has a
:messagefield (use the default behavior) - You don't need validation or computation at raise time
Over-application example:
defmodule NotFoundError do
defexception [:resource, :message]
# Custom exception/1 that does nothing the default doesn't
def exception(opts) do
resource = Keyword.get(opts, :resource, "item")
%NotFoundError{resource: resource, message: "#{resource} not found"}
end
end
Better alternative:
defmodule NotFoundError do
defexception [:resource]
def message(%{resource: resource}), do: "#{resource} not found"
end
Why: The message/1 callback already handles formatting from fields. Use custom exception/1 only when you need validation (fetch!), transient inputs that don't map 1:1 to fields, or computation that can't be deferred to message/1.
8. raise Macro Internals: Compile-Time Type Resolution
Source: lib/elixir/lib/kernel.ex#L2246
What it does: The raise macro inspects the argument at compile time to determine if it's a string, binary expression, atom (module), or existing exception struct, generating optimized code for each case.
defmacro raise(message) do
message =
case not is_binary(message) and bootstrapped?(Macro) do
true -> Macro.expand(message, __CALLER__)
false -> message
end
erlang_error = fn x ->
quote do
:erlang.error(unquote(x), :none, error_info: %{module: Exception})
end
end
case message do
message when is_binary(message) ->
erlang_error.(quote do: RuntimeError.exception(unquote(message)))
{:<<>>, _, _} = message ->
erlang_error.(quote do: RuntimeError.exception(unquote(message)))
alias when is_atom(alias) ->
erlang_error.(quote do: unquote(alias).exception([]))
_ ->
erlang_error.(quote do: Kernel.Utils.raise(unquote(message)))
end
end
Why: By resolving at compile time, the generated code avoids runtime dispatch to figure out what kind of exception to create. String → RuntimeError, atom → that module's exception, struct → re-raise as-is.
When to Use
Triggers:
- You're writing a macro that processes error-related inputs and want to generate optimal code
- You need to dispatch on input types at compile time to avoid runtime overhead
- You're building a framework that wraps Elixir's error machinery
Example — before:
# Runtime dispatch on every call
defmacro my_raise(thing) do
quote do
cond do
is_binary(unquote(thing)) -> raise RuntimeError, unquote(thing)
is_atom(unquote(thing)) -> raise unquote(thing)
true -> raise unquote(thing)
end
end
end
Example — after:
# Compile-time dispatch — no runtime branching
defmacro my_raise(thing) do
expanded = Macro.expand(thing, __CALLER__)
case expanded do
msg when is_binary(msg) ->
quote do: raise(RuntimeError, message: unquote(msg))
alias when is_atom(alias) ->
quote do: raise(unquote(alias))
_ ->
quote do: raise(unquote(thing))
end
end
When NOT to Use
Don't use this when:
- You're writing application code (just use
raisenormally — it already does this for you) - The input type isn't known at compile time (dynamic user input)
- The optimization doesn't matter (error paths are cold by definition)
Over-application example:
# Don't reimplement raise's internals in your own macros
defmacro validate!(condition, module) do
quote do
unless unquote(condition) do
:erlang.error(unquote(module).exception([]), :none, error_info: %{module: Exception})
end
end
end
Better alternative:
defmacro validate!(condition, module) do
quote do
unless unquote(condition), do: raise(unquote(module))
end
end
Why: Elixir's raise already performs compile-time optimization. Re-implementing its internals couples your code to implementation details that may change. Use raise directly unless you have a genuinely novel dispatch requirement.
9. Error Normalization: Erlang → Elixir Exception Translation
Source: lib/elixir/lib/exception.ex#L2530 (ErlangError.normalize/2)
What it does: Translates raw Erlang error reasons (atoms and tuples) into proper Elixir exception structs with helpful messages.
def normalize(:badarg, stacktrace) do
case error_info(:badarg, stacktrace, "errors were found at the given arguments") do
{:ok, reason, details} -> %ArgumentError{message: reason <> details}
:error -> %ArgumentError{}
end
end
def normalize(:badarith, _stacktrace), do: %ArithmeticError{}
def normalize({:badarity, {fun, args}}, _stacktrace) do
%BadArityError{function: fun, args: args}
end
def normalize({:badkey, key}, stacktrace) do
term =
case stacktrace do
[{Map, :get_and_update!, [map, _, _], _} | _] -> map
[{Map, :update!, [map, _, _], _} | _] -> map
[{:maps, :update, [_, _, map], _} | _] -> map
[{:maps, :get, [_, map], _} | _] -> map
[{:erlang, :map_get, [_, map], _} | _] -> map
_ -> nil
end
%KeyError{key: key, term: term}
end
Why: Erlang errors are bare atoms/tuples. Elixir provides rich context (the map that was accessed, the function that was called). The stacktrace inspection to extract term from {:badkey, key} errors is a particularly clever pattern — it looks at what function was on top of the stack to find the map argument.
When to Use
Triggers:
- You're wrapping an Erlang library or NIF that returns raw error tuples/atoms
- Your application interacts with OTP components that signal errors in Erlang style
- You want to provide Elixir-idiomatic error messages for foreign error shapes
Example — before:
# Raw Erlang errors leak through to users
case :crypto.hash(:sha256, data) do
result when is_binary(result) -> {:ok, result}
# Crashes with {:badarg, ...} — confusing for Elixir users
end
Example — after:
def hash(algorithm, data) do
:crypto.hash(algorithm, data)
rescue
e in ErlangError ->
reraise normalize_crypto_error(e.original, algorithm, data), __STACKTRACE__
end
defp normalize_crypto_error(:badarg, algorithm, _data) do
%ArgumentError{message: "unsupported hash algorithm: #{inspect(algorithm)}"}
end
When NOT to Use
Don't use this when:
- You're working entirely within Elixir code that already returns proper exceptions
- The Erlang error is passed through to OTP (supervisors, GenServers) that handle it natively
- The error is never shown to users and is only logged internally
Over-application example:
# Normalizing errors that supervisors handle fine as-is
def init(_) do
case :ets.new(:my_table, [:set]) do
ref when is_reference(ref) -> {:ok, ref}
# :ets.new raises :badarg — don't catch and wrap this,
# let the supervisor handle the crash
end
end
Better alternative:
# Let it crash — the supervisor will restart with backoff
def init(_) do
table = :ets.new(:my_table, [:set])
{:ok, %{table: table}}
end
Why: Error normalization adds value at boundaries where humans read errors. Inside OTP supervision trees, raw crashes are fine — the supervisor handles restart. Don't normalize errors that nobody will read.
10. blame/2 Callback: Enriching Exceptions After the Fact
Source: lib/elixir/lib/exception.ex#L2200 (KeyError.blame)
What it does: The optional blame/2 callback enriches an exception with additional context that's expensive to compute (like "did you mean?" suggestions).
@impl true
def blame(exception, stacktrace) do
%{term: term, key: key} = exception
message = message(key, term)
if is_atom(key) and (map_with_atom_keys_only?(term) or Keyword.keyword?(term)) do
hint = did_you_mean(key, available_keys(term))
message = message <> IO.iodata_to_binary(hint)
{%{exception | message: message}, stacktrace}
else
{%{exception | message: message}, stacktrace}
end
end
Why: Computing string distance for "did you mean?" is expensive. It only makes sense when displaying the error to a human (in IEx, crash reports). The blame/2 callback is called lazily by Exception.blame/3, not on every raise. This keeps the hot path fast.
When to Use
Triggers:
- You can provide expensive-but-helpful context (suggestions, similar names, valid options)
- The enrichment involves computation that shouldn't happen on every raise (string distance, DB lookups)
- Your exception is commonly seen in development/debugging and benefits from extra hints
Example — before:
defmodule Router.NoRouteError do
defexception [:path, :method, :message]
def message(%{path: path, method: method}) do
"no route found for #{method} #{path}"
end
end
# Error just says "no route" with no help
Example — after:
defmodule Router.NoRouteError do
defexception [:path, :method, :available_routes, :message]
def message(%{path: path, method: method}) do
"no route found for #{method} #{path}"
end
def blame(%{path: path} = exception, stacktrace) do
suggestions = find_similar_routes(path)
if suggestions != [] do
hint = "\n\nDid you mean:\n" <> Enum.map_join(suggestions, "\n", &" • #{&1}")
{%{exception | message: message(exception) <> hint}, stacktrace}
else
{%{exception | message: message(exception)}, stacktrace}
end
end
end
When NOT to Use
Don't use this when:
- The enrichment is cheap enough to always include in
message/1 - The exception is raised in production hot paths where blame is never called
- You'd need side effects (network calls, disk I/O) in the blame callback
Over-application example:
# blame/2 that queries the database for suggestions
def blame(exception, stacktrace) do
similar = Repo.all(from u in User, where: ilike(u.name, ^"%#{exception.name}%"))
hint = "Did you mean: #{Enum.map_join(similar, ", ", & &1.name)}"
{%{exception | message: exception.message <> "\n" <> hint}, stacktrace}
end
Better alternative:
# Keep blame pure — only use data already on the exception
def blame(%{name: name, available: available} = exception, stacktrace) do
hint = did_you_mean(name, available)
{%{exception | message: exception.message <> hint}, stacktrace}
end
Why: blame/2 runs in the context of error formatting, potentially in a crashing process. Side effects (DB queries, HTTP calls) can fail or hang, making error reporting itself unreliable. Only use data already available on the exception or in the stacktrace.
11. Guards for Type Dispatch in Error Handling
Source: lib/elixir/lib/exception.ex#L2530, lib/elixir/lib/map.ex#L586
What it does: Guards (when is_map(term), when is_list(term)) dispatch to different error handling or normalization logic without using conditionals.
# Keyword.pop_first — uses :lists.keytake, pattern matches the result
def pop_first(keywords, key, default \\ nil) when is_list(keywords) and is_atom(key) do
case :lists.keytake(key, 1, keywords) do
{:value, {^key, value}, rest} -> {value, rest}
false -> {default, keywords}
end
end
# ErlangError.normalize — pattern matches erlang error shapes
def normalize({:badkey, key, map}, _stacktrace) when is_map(map) do
%KeyError{key: key, term: map}
end
def normalize({:badkey, key, term}, _stacktrace) do
# non-map term gets a more detailed message
message = "key #{inspect(key)} not found in: #{inspect(term)}..."
%KeyError{key: key, term: term, message: message}
end
Why: The guard when is_map(map) lets the BEAM dispatch directly to the right clause without entering the function body. When the same error has different handling based on the term type, guards make each path explicit and independently testable.
When to Use
Triggers:
- You have multiple function clauses handling the same error shape but with different term types
- The BEAM's pattern matching + guards can select the right clause without runtime
cond/case - Each type variant needs genuinely different handling (not just different messages)
Example — before:
def format_validation_error(field, value) do
cond do
is_binary(value) -> "#{field}: string too long (#{String.length(value)} chars)"
is_integer(value) -> "#{field}: number out of range (#{value})"
is_list(value) -> "#{field}: too many items (#{length(value)})"
true -> "#{field}: invalid value #{inspect(value)}"
end
end
Example — after:
def format_validation_error(field, value) when is_binary(value) do
"#{field}: string too long (#{String.length(value)} chars)"
end
def format_validation_error(field, value) when is_integer(value) do
"#{field}: number out of range (#{value})"
end
def format_validation_error(field, value) when is_list(value) do
"#{field}: too many items (#{length(value)})"
end
def format_validation_error(field, value) do
"#{field}: invalid value #{inspect(value)}"
end
When NOT to Use
Don't use this when:
- You only have one or two cases (a simple
iforcaseis clearer) - The dispatch logic involves conditions that guards can't express (complex comparisons, function calls)
- The different clauses share most of their logic (DRY violation)
Over-application example:
# Guards for trivially different behavior
def log_error(reason) when is_atom(reason), do: Logger.error("Error: #{reason}")
def log_error(reason) when is_binary(reason), do: Logger.error("Error: #{reason}")
def log_error(reason), do: Logger.error("Error: #{inspect(reason)}")
Better alternative:
def log_error(reason) do
Logger.error("Error: #{if is_binary(reason) or is_atom(reason), do: reason, else: inspect(reason)}")
end
Why: When multiple guard clauses have nearly identical bodies, the guards add visual noise without meaningful dispatch. Consolidate when the bodies are the same or trivially different.
12. The :error / {:error, reason} Convention Split
Source: lib/elixir/lib/map.ex (Map.fetch → :error), lib/elixir/lib/exception.ex#L2695 (error_info → {:ok, ...} | :error)
What it does: Elixir has two error return conventions:
:erroralone — when there's only one failure mode (Map.fetch, Access){:error, reason}— when the caller needs to distinguish failure modes (File.read, GenServer)
# Convention 1: bare :error (only one failure mode possible)
@spec fetch(map, key) :: {:ok, value} | :error
def fetch(map, key), do: :maps.find(key, map)
# Convention 2: {:error, reason} (multiple failure modes)
# From File module (not shown in source but referenced):
@spec read(Path.t()) :: {:ok, binary} | {:error, posix}
Why: The convention matches information content. If you can't add information to a bare :error, don't wrap it in a tuple. If the reason matters (:enoent vs :eacces), use the tuple form. The with macro works elegantly with both:
# Bare :error falls through with as the unmatched value
with {:ok, val} <- Map.fetch(map, :key), do: val
# Returns :error
# Tuple error falls through with its reason intact
with {:ok, content} <- File.read(path), do: content
# Returns {:error, :enoent}
When to Use
Triggers:
- Designing a function's return type: ask "how many distinct ways can this fail?"
- If exactly one → bare
:error - If multiple distinguishable failures →
{:error, reason} - If the function already follows an OTP/standard convention → match it
Example — before:
# Inconsistent — sometimes tuple, sometimes bare
def find_user(id) do
case users[id] do
nil -> {:error, :not_found} # Only failure mode — why a tuple?
user -> {:ok, user}
end
end
Example — after:
# Bare :error — lookup has one failure mode
def find_user(id) do
case users[id] do
nil -> :error
user -> {:ok, user}
end
end
When NOT to Use
Don't use this when:
- You're uncertain whether more failure modes will be added later (start with
{:error, reason}for forward-compatibility) - The function is part of a public API where bare
:errorwould surprise users expecting a reason - The function crosses a context boundary where "why it failed" will inevitably be asked
Over-application example:
# Bare :error for something with multiple failure modes
def authenticate(token) do
cond do
expired?(token) -> :error # Was it expired? Invalid? Revoked?
revoked?(token) -> :error
!valid_sig?(token) -> :error
true -> {:ok, decode(token)}
end
end
Better alternative:
def authenticate(token) do
cond do
expired?(token) -> {:error, :expired}
revoked?(token) -> {:error, :revoked}
!valid_sig?(token) -> {:error, :invalid_signature}
true -> {:ok, decode(token)}
end
end
Why: When you collapse multiple distinct failures into bare :error, callers can't log, retry, or display meaningful feedback. Use bare :error only when there's genuinely one indistinguishable failure mode.
13. reduce_while — Early Exit Without Exceptions
Source: lib/elixir/lib/enum.ex#L2660
What it does: reduce_while uses {:cont, acc} / {:halt, acc} tuples as the reducer's return value to signal continuation or early termination.
@spec reduce_while(t, any, (element, any -> {:cont, any} | {:halt, any})) :: any
def reduce_while(enumerable, acc, fun) do
Enumerable.reduce(enumerable, {:cont, acc}, fun) |> elem(1)
end
# Usage (from docs):
Enum.reduce_while(1..100, 0, fn x, acc ->
if x < 3, do: {:cont, acc + x}, else: {:halt, acc}
end)
#=> 3
Why: This is control flow without exceptions. The tagged tuples {:cont, ...} and {:halt, ...} communicate intent to the enumeration machinery. This is more efficient than throw/catch for expected early exits and more composable than exceptions.
Anti-pattern: Using exceptions for expected early termination:
# BAD — exceptions for flow control
try do
Enum.reduce(1..100, 0, fn x, acc ->
if x >= 3, do: throw(acc), else: acc + x
end)
catch
:throw, value -> value
end
When to Use
Triggers:
- You need to accumulate values from a collection but may stop early based on a condition
- The "stop" condition is a normal outcome, not an error (e.g., "found what I needed", "budget exhausted")
- You want the accumulated value at the point of stopping
Example — before:
# Awkward workaround with Enum.reduce + special accumulator
def take_until_budget(items, budget) do
{taken, _} =
Enum.reduce(items, {[], budget}, fn
_item, {taken, remaining} when remaining <= 0 -> {taken, remaining}
item, {taken, remaining} -> {[item | taken], remaining - item.cost}
end)
Enum.reverse(taken)
end
# Problem: still iterates ALL items even after budget is exhausted
Example — after:
def take_until_budget(items, budget) do
items
|> Enum.reduce_while({[], budget}, fn
item, {taken, remaining} when item.cost > remaining ->
{:halt, {taken, remaining}}
item, {taken, remaining} ->
{:cont, {[item | taken], remaining - item.cost}}
end)
|> elem(0)
|> Enum.reverse()
end
When NOT to Use
Don't use this when:
- You always process the entire collection (use
Enum.reduce/3) - You just need to find the first matching element (use
Enum.find/2) - You want to filter or transform all elements (use
Enum.filter/2orEnum.map/2) - The early exit signals a true error condition that should propagate
Over-application example:
# reduce_while when Enum.find would suffice
Enum.reduce_while(users, nil, fn user, _acc ->
if user.admin?, do: {:halt, user}, else: {:cont, nil}
end)
Better alternative:
Enum.find(users, & &1.admin?)
Why: reduce_while is for accumulating values with an early-exit condition. When you're just searching for one element, Enum.find or Enum.find_value is more expressive and immediately communicates intent.
14. Three-Tier Error Strategy in Map Operations
Source: lib/elixir/lib/map.ex#L290
What it does: Map provides three variants for key operations, each with different error semantics:
| Function | Missing Key Behavior | Use Case |
|---|---|---|
Map.get/3 |
Returns default | Optional keys, config with fallbacks |
Map.fetch/2 |
Returns :error |
Composable error handling, with chains |
Map.fetch!/2 |
Raises KeyError |
Assertions, keys guaranteed to exist |
# Tier 1: Silent default (line 586)
def get(map, key, default \\ nil) # → value | default
# Tier 2: Error value (line 290)
def fetch(map, key) # → {:ok, value} | :error
# Tier 3: Raise (line 311)
def fetch!(map, key) # → value | raise KeyError
Why: Different call sites have different error handling needs. Library code that composes operations uses fetch. Application code with known preconditions uses fetch!. UI/config code that needs defaults uses get. The three-tier pattern provides the right tool for each situation.
Anti-pattern: Only providing the bang version and forcing callers to rescue:
# BAD — only one option, forces try/rescue for non-exceptional cases
def get_config(key) do
Map.fetch!(config, key)
end
When to Use
Triggers:
- You're designing a module with lookup/access operations that can fail
- Callers will have different error-handling needs (some compose, some assert, some use defaults)
- You want an API that guides users toward the right error strategy for their context
Example — before:
# Only one way to access — forces awkward workarounds
defmodule Cache do
def get(key) do
case :ets.lookup(:cache, key) do
[{^key, value}] -> value
[] -> nil # Callers can't distinguish "key missing" from "stored nil"
end
end
end
Example — after:
defmodule Cache do
# Tier 1: Default value (config, optional data)
def get(key, default \\ nil) do
case fetch(key) do
{:ok, value} -> value
:error -> default
end
end
# Tier 2: Composable (with chains, conditional logic)
def fetch(key) do
case :ets.lookup(:cache, key) do
[{^key, value}] -> {:ok, value}
[] -> :error
end
end
# Tier 3: Assertion (key must exist, crash otherwise)
def fetch!(key) do
case fetch(key) do
{:ok, value} -> value
:error -> raise KeyError, key: key, term: :cache
end
end
end
When NOT to Use
Don't use this when:
- The module only has one natural access pattern (no need to force three variants)
- The operation always succeeds by design (no failure mode to tier)
- You're writing internal/private functions where only one caller exists
Over-application example:
# Three tiers for a function with no meaningful "default" behavior
defmodule PasswordHasher do
def hash(password), do: Bcrypt.hash_pwd_salt(password)
def hash!(password), do: Bcrypt.hash_pwd_salt(password) # Same thing?
def try_hash(password) do
{:ok, Bcrypt.hash_pwd_salt(password)} # Never fails — useless tuple
end
end
Better alternative:
defmodule PasswordHasher do
def hash(password), do: Bcrypt.hash_pwd_salt(password)
end
Why: The three-tier pattern only makes sense when failure is a real possibility and different callers genuinely need different responses to that failure. Don't cargo-cult it onto functions that always succeed or have a single calling context.
Decision Tree
- If you have 2+ sequential steps that each return a value to pattern-match → use
withwith normalized error shapes (Pattern 1) - If the caller only cares success vs failure (not which step failed) → use
with+else _ -> :errorcatch-all (Pattern 2) - If extracting nested data from loosely-structured inputs (stacktraces, metadata) → chain pattern matching in
with(Pattern 3) - If a function has exactly one failure mode obvious from context → return bare
:error(Pattern 4) - If failure means a bug (preconditions guarantee success) → provide a bang variant that raises (Pattern 5)
- If callers need to programmatically inspect error context → use
defexceptionwith structured fields (Pattern 6) - If the exception message is computed from multiple fields or requires validation → override
exception/1(Pattern 7) - If wrapping an Erlang library that returns raw error atoms/tuples → normalize to Elixir exceptions at the boundary (Pattern 9)
- If you can provide expensive but helpful context (did-you-mean suggestions) → implement
blame/2(Pattern 10) - If multiple distinct failure modes exist → use
{:error, reason}tuples; if only one → use bare:error(Pattern 12) - If you need early exit from iteration without exceptions → use
reduce_whilewith{:cont, acc}/{:halt, acc}(Pattern 13) - If designing a module with lookup operations for different caller needs → provide three tiers: get/fetch/fetch! (Pattern 14)