Files
elixir-patterns/patterns/error-handling.md
T
Aaron Weiker 4ea9a884aa 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.
2026-04-29 22:50:12 -07:00

17 KiB
Raw Blame History

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).

# 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 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.

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 26952720 (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 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).

@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 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.

# 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:

# 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.

# 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 22552270 (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 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.

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.

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).

@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.

# 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)
# 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 26602676

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 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
# 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