docs: idiomatic Elixir and Phoenix patterns with source citations

Extracted patterns, conventions, and code smells directly from the
Elixir and Phoenix source code with file path and line number citations.

Covers: GenServer, error handling, data transforms, process design,
testing, documentation, typespecs, macros, behaviours, module organization,
Phoenix-specific patterns, framework deviations, and anti-patterns.
This commit is contained in:
Aaron Weiker
2026-04-29 22:50:12 -07:00
commit 4ea9a884aa
16 changed files with 4857 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
# Error Handling Patterns in Elixir Core
Patterns extracted from Elixir's standard library source code.
---
## 1. The `with` Macro — Normalized Error Clauses
**Source:** `lib/elixir/lib/kernel/special_forms.ex` lines 16001715 (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`).
```elixir
# 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:
```elixir
# 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
```
---
## 2. Real-World `with` — Multi-Step Fallible Operations
**Source:** `lib/elixir/lib/exception.ex` lines 251285 (`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.
```elixir
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:
```elixir
# 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
```
---
## 3. Another `with` — Error Info Extraction
**Source:** `lib/elixir/lib/exception.ex` lines 26952720 (`error_info/3` in ErlangError)
**What it does:** Chains pattern matching on a stacktrace and error_info map to extract formatted error details.
```elixir
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.
---
## 4. `{:ok, value}` / `:error` Convention (Map.fetch)
**Source:** `lib/elixir/lib/map.ex` lines 290309
**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).
```elixir
@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`:
```elixir
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:
```elixir
# 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
```
---
## 5. Bang Functions: Raise on Error (`fetch!` vs `fetch`)
**Source:** `lib/elixir/lib/map.ex` lines 311380
**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.
```elixir
# 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:
1. Code that needs to handle missing keys gracefully → use `fetch/2`
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:
```elixir
# 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
```
---
## 6. Exception Structure: `defexception` Fields
**Source:** `lib/elixir/lib/exception.ex` lines 22502500 (exception definitions)
**What it does:** Exceptions carry meaningful fields beyond just `:message`. The `message/1` callback generates a human-readable string from those fields.
```elixir
# 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:
```elixir
# 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
```
---
## 7. Custom `exception/1` Callback for Ergonomic Raising
**Source:** `lib/elixir/lib/exception.ex` lines 22552270 (UnicodeConversionError)
**What it does:** Override `exception/1` to accept raw values (not just keyword lists) and build the struct with a meaningful message.
```elixir
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:
```elixir
raise UnicodeConversionError, encoded: data, kind: "invalid", rest: <<0xFF>>
```
---
## 8. `raise` Macro Internals: Compile-Time Type Resolution
**Source:** `lib/elixir/lib/kernel.ex` lines 22462294
**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.
```elixir
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.
---
## 9. Error Normalization: Erlang → Elixir Exception Translation
**Source:** `lib/elixir/lib/exception.ex` lines 25302680 (`ErlangError.normalize/2`)
**What it does:** Translates raw Erlang error reasons (atoms and tuples) into proper Elixir exception structs with helpful messages.
```elixir
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.
---
## 10. `blame/2` Callback: Enriching Exceptions After the Fact
**Source:** `lib/elixir/lib/exception.ex` lines 22002215 (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).
```elixir
@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.
---
## 11. Guards for Type Dispatch in Error Handling
**Source:** `lib/elixir/lib/exception.ex` lines 25302550, `lib/elixir/lib/map.ex` lines 586594
**What it does:** Guards (`when is_map(term)`, `when is_list(term)`) dispatch to different error handling or normalization logic without using conditionals.
```elixir
# 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.
---
## 12. The `:error` / `{:error, reason}` Convention Split
**Source:** `lib/elixir/lib/map.ex` (Map.fetch → `:error`), `lib/elixir/lib/exception.ex` lines 26952720 (`error_info``{:ok, ...} | :error`)
**What it does:** Elixir has two error return conventions:
1. **`:error`** alone — when there's only one failure mode (Map.fetch, Access)
2. **`{:error, reason}`** — when the caller needs to distinguish failure modes (File.read, GenServer)
```elixir
# 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:
```elixir
# 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}
```
---
## 13. `reduce_while` — Early Exit Without Exceptions
**Source:** `lib/elixir/lib/enum.ex` lines 26602676
**What it does:** `reduce_while` uses `{:cont, acc}` / `{:halt, acc}` tuples as the reducer's return value to signal continuation or early termination.
```elixir
@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:
```elixir
# 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
```
---
## 14. Three-Tier Error Strategy in Map Operations
**Source:** `lib/elixir/lib/map.ex` lines 290430
**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 |
```elixir
# 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:
```elixir
# BAD — only one option, forces try/rescue for non-exceptional cases
def get_config(key) do
Map.fetch!(config, key)
end
```