Files
aweiker 10218813d3 docs: backfill TOC + decision trees, fix review findings
- 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"
2026-05-01 22:13:35 -07:00

49 KiB

Error Handling Patterns in Elixir Core

Patterns extracted from Elixir's standard library source code.

Contents

  1. The with Macro — Normalized Error Clauses
  2. Real-World with — Multi-Step Fallible Operations
  3. Another with — Error Info Extraction
  4. {:ok, value} / :error Convention (Map.fetch)
  5. Bang Functions: Raise on Error (fetch! vs fetch)
  6. Exception Structure: defexception Fields
  7. Custom exception/1 Callback for Ergonomic Raising
  8. raise Macro Internals: Compile-Time Type Resolution
  9. Error Normalization: Erlang → Elixir Exception Translation
  10. blame/2 Callback: Enriching Exceptions After the Fact
  11. Guards for Type Dispatch in Error Handling
  12. The :error / {:error, reason} Convention Split
  13. reduce_while — Early Exit Without Exceptions
  14. 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 _ -> :error would 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 with or 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:

  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

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 rescue and 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 :message field (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 raise normally — 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 if or case is 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:

  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}

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 :error would 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/2 or Enum.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 with with normalized error shapes (Pattern 1)
  • If the caller only cares success vs failure (not which step failed) → use with + else _ -> :error catch-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 defexception with 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_while with {: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)