docs: add when/when-not to error-handling

This commit is contained in:
Aaron Weiker
2026-04-30 05:40:11 -07:00
parent cb94a157a1
commit 8f606d40d7
3 changed files with 3196 additions and 0 deletions
+906
View File
@@ -45,6 +45,64 @@ else
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:**
```elixir
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:**
```elixir
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:**
```elixir
# Overkill — single step doesn't need `with`
with {:ok, user} <- Repo.get_user(id) do
{:ok, user}
end
```
**Better alternative:**
```elixir
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
@@ -82,6 +140,84 @@ case :code.which(module) do
# ... 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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
@@ -113,6 +249,75 @@ 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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)
@@ -147,6 +352,63 @@ def fetch(map, key) do
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:**
```elixir
def get_env(key) do
case System.get_env(key) do
nil -> {:error, :not_set}
value -> {:ok, value}
end
end
```
**Example — after:**
```elixir
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:**
```elixir
# 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:**
```elixir
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`)
@@ -187,6 +449,60 @@ case Map.fetch(map, key) do
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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
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
@@ -226,6 +542,65 @@ 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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
@@ -260,6 +635,77 @@ end
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:**
```elixir
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:**
```elixir
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:**
```elixir
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:**
```elixir
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
@@ -297,6 +743,74 @@ 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
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
@@ -335,6 +849,66 @@ 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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
@@ -361,6 +935,75 @@ 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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
@@ -392,6 +1035,68 @@ 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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
@@ -424,6 +1129,70 @@ 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
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
@@ -459,6 +1228,65 @@ catch
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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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
@@ -493,3 +1321,81 @@ 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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.
File diff suppressed because it is too large Load Diff
+1181
View File
File diff suppressed because it is too large Load Diff