docs: add when/when-not to error-handling
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user