Err
Error handling for Elixir. Let it flow.
Err is a tiny library for composing and normalizing error flows in Elixir.
It works with the conventions Elixir developers already use:
{:ok, value}and{:error, reason}nilas absence- existing return values from Phoenix, Ecto, Oban, and your own code
Instead of introducing a new result type or DSL, Err helps turn mixed return styles into
flows that are easier to compose, transform, and reason about.
Use it to:
- compose result pipelines cleanly
-
normalize
nil, tuples, and exception-based APIs - transform errors close to the boundary
-
keep
with-based application code readable
Features
-
Works with existing
:ok/:errortuples of any size -
Treats
nilas absence for Option-style flows -
Normalizes values into result flows with
from_nil/2andtry_rescue/2 -
Wraps
Taskwork withasync/1,await/2, andawait_many/2 -
Composes success and error paths with
map/2,map_err/2,and_then/2, andor_else/2 -
Adds side effects without changing values using
tap/2andtap_err/2 -
Keeps branching explicit with
match/2 -
Includes list helpers like
all/1,values/1, andpartition/1 -
Ships with exception helpers like
wrap/1andmessage/1
Installation
Add err to your list of dependencies in mix.exs:
def deps do
[
{:err, "~> 0.2"}
]
endWhy Err?
Elixir already has excellent primitives for error handling: pattern matching, with, case,
and tagged tuples.
The friction usually starts when application code combines several styles from several libraries:
Repo.get/2returnsnilRepo.insert/2returns{:ok, struct}or{:error, changeset}- some APIs raise exceptions
- others return custom tuples or atoms
Err is a small glue layer for normalizing those differences.
Usage
Wrap values
iex> Err.ok(42)
{:ok, 42}
iex> Err.error(:timeout)
{:error, :timeout}Normalize nil into a result
iex> Err.from_nil("config.json", :not_found)
{:ok, "config.json"}
iex> Err.from_nil(nil, :not_found)
{:error, :not_found}Convert raising code into a result
iex> Err.try_rescue(fn -> 100 + 23 end)
{:ok, 123}
iex> Err.try_rescue(fn -> raise "boom" end) |> Err.map_err(&Exception.message/1)
{:error, "boom"}Run Task work through results
iex> task = Err.async(fn -> 40 + 2 end)
iex> Err.await(task)
{:ok, 42}
iex> [Task.async(fn -> 1 end), Task.async(fn -> {:error, :boom} end)] |> Err.await_many()
[{:ok, 1}, {:error, :boom}]Unwrap with defaults
iex> Err.unwrap_or({:ok, "config.json"}, "default.json")
"config.json"
iex> Err.unwrap_or({:error, :not_found}, "default.json")
"default.json"Transform success values
iex> Err.map({:ok, 5}, fn num -> num * 2 end)
{:ok, 10}Transform error values
iex> Err.map_err({:error, :timeout}, fn reason -> "#{reason}_error" end)
{:error, "timeout_error"}Chain operations
iex> Err.and_then({:ok, 5}, fn num -> {:ok, num * 2} end)
{:ok, 10}Add side effects without changing the result
iex> Err.tap({:ok, 5}, fn value -> send(self(), {:seen, value}) end)
{:ok, 5}
iex> Err.tap_err({:error, :timeout}, fn reason -> send(self(), {:seen_error, reason}) end)
{:error, :timeout}Branch explicitly at the boundary
iex> Err.match({:ok, 5}, ok: &(&1 * 2), error: fn _ -> 0 end)
10
iex> Err.match(nil, ok: & &1, error: fn _ -> :missing end)
:missingFlatten nested results
iex> Err.flatten({:ok, {:ok, 1}})
{:ok, 1}Eager fallback
iex> Err.or_else({:error, :cache_miss}, {:ok, "disk.db"})
{:ok, "disk.db"}Lazy fallback
iex> Err.or_else_lazy({:error, :cache_miss}, fn _reason -> {:ok, "disk.db"} end)
{:ok, "disk.db"}Combine results (fail fast)
iex> Err.all([{:ok, 1}, {:ok, 2}, {:ok, 3}])
{:ok, [1, 2, 3]}
iex> Err.all([{:ok, 1}, {:error, :timeout}])
{:error, :timeout}Extract ok values
iex> Err.values([{:ok, 1}, {:error, :x}, {:ok, 2}])
[1, 2]Split into ok and error lists
iex> Err.partition([{:ok, 1}, {:error, "a"}, {:ok, 2}])
{[1, 2], ["a"]}Check if result is ok
def process(result) when Err.is_ok(result) do
result
endCheck if result is error
def process(result) when Err.is_err(result) do
result
endReal-World Example
def fetch_user_profile(id) do
with {:ok, user} <- Repo.get(User, id) |> Err.from_nil(:not_found),
{:ok, account} <- Accounts.fetch_account(user) |> Err.map_err(&normalize_error/1),
{:ok, stats} <- Stats.fetch(account) |> Err.map_err(&normalize_error/1) do
{:ok, %{user: user, account: account, stats: stats}}
end
endErr complements with, case, and pattern matching. It does not try to replace them.