4ea9a884aa
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.
496 lines
17 KiB
Markdown
496 lines
17 KiB
Markdown
# 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 1600–1715 (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 251–285 (`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 2695–2720 (`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 290–309
|
||
|
||
**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 311–380
|
||
|
||
**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 2250–2500 (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 2255–2270 (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 2246–2294
|
||
|
||
**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 2530–2680 (`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 2200–2215 (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 2530–2550, `lib/elixir/lib/map.ex` lines 586–594
|
||
|
||
**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 2695–2720 (`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 2660–2676
|
||
|
||
**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 290–430
|
||
|
||
**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
|
||
```
|