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.
17 KiB
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).
# 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
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.
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
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.
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).
@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
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.
# 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
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.
# 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
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.
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>>
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.
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.
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).
@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.
# 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:
: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}
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.
@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
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 |
# 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