Err

Hex.pmDocs

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:

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:

Features

Installation

Add err to your list of dependencies in mix.exs:

def deps do
  [
    {:err, "~> 0.2"}
  ]
end

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

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)
:missing

Flatten 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
end

Check if result is error

def process(result) when Err.is_err(result) do
  result
end

Real-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
end

Err complements with, case, and pattern matching. It does not try to replace them.