Errata

CI

Errata is an Elixir library for structured, named error handling.

In Elixir it is common to signal failure either by returning an error tuple ({:error, reason}) or by raising an exception. Errata embraces both styles, but replaces ad-hoc reasons and loosely structured exceptions with named, structured error types that share a consistent shape and carry full contextual detail about what went wrong and where.

Taken together, an application's Errata types form a kind of errata sheet for the system: a deliberate, named catalogue of the ways it can fail.

Each Errata error is an Exception struct with a well-defined set of fields:

Because the full context is embedded in the struct, it travels with the error whether the error is raised or returned as a value, and can be logged, reported, or rendered to JSON at the boundaries of the system without losing the information needed to interpret it.

With Errata you can:

Quick start

# Define a domain error. Errata generates the exception struct, the
# `Errata.Error` behaviour, and the String.Chars and Jason.Encoder protocols.
defmodule MyApp.Orders.OrderNotFound do
use Errata.DomainError,
default_message: "the requested order does not exist"
end
defmodule MyApp.Orders do
require Errata
# Return the error as a value, capturing the reason, some context, and the
# point of origin (via `Errata.create/2`).
def fetch_order(id) do
with :error <- lookup(id) do
{:error, Errata.create(MyApp.Orders.OrderNotFound, reason: :not_found, context: %{order_id: id})}
end
end
# ...or raise the very same type as an exception.
def fetch_order!(id) do
case fetch_order(id) do
{:ok, order} -> order
{:error, error} -> raise error
end
end
end

An Errata error carries its full context with it, and can be rendered to a string or to JSON for logging and error reporting:

error = MyApp.Orders.OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
to_string(error)
#=> "the requested order does not exist: :not_found"
Jason.encode!(error)
#=> ~s({"error_type":"MyApp.Orders.OrderNotFound","reason":"not_found", ...})

The three kinds of errors

Every Errata error has a kind, fixed when the type is defined:

An error's kind decides how a boundary treats it; its type decides how your domain logic behaves. For how to choose between them, what each kind defaults to, and how to opt out of the taxonomy entirely, see the design notes.

Defining custom error types

Most errors in an application are either domain errors or infrastructure errors, so Errata provides a dedicated module for each. Prefer these two when defining custom error types: they make the classification explicit and let domain and infrastructure errors be identified throughout the system.

defmodule MyApp.Orders.PaymentDeclined do
# A business-rule violation or other error within the problem domain.
use Errata.DomainError
end
defmodule MyApp.Orders.PaymentGatewayTimeout do
# A network timeout, database failure, or other infrastructure-level error.
use Errata.InfrastructureError
end

For the occasional error that fits neither category — such as an error originating in library code — use the base Errata.Error module, which creates an error of kind :general:

defmodule MyApp.UnexpectedError do
# An error that is neither a domain nor an infrastructure error.
use Errata.Error
end

Every option is optional. The two you are likely to reach for first:

The rest are classifications consumed at a boundary — :http_status, :code, :severity, :retryable — plus :reasons (declare the valid reasons for the type), :redact (keep sensitive context out of logs and JSON), and :aggregate (a type that holds several errors at once). See Errors at a boundary, Reporting errors, and Wrapping and composing errors, or Errata.Error for the full reference.

Whichever module you use, the resulting error type is an exception struct that conforms to the t:Errata.error/0 type, implements the Errata.Error behaviour, and provides String.Chars and Jason.Encoder implementations so that it can be rendered as a string or encoded as JSON automatically.

Creating errors as return values

Returning an error as a value — preferably wrapped in an {:error, error} tuple — lets you create the error with full context at the site where it occurs, while leaving the handling of the error to callers further up the stack. The error can then be logged or reported at a system boundary without losing any of its context.

There are three ways to create an error. They differ in how much setup they need and in whether they record where the error came from.

Errata.create/2 is the one to reach for by default. It captures the current __ENV__ and stacktrace into the :env field, and because it takes the error type as an argument, a single use Errata covers every error type the module creates — there is no per-type require:

iex> require Errata
iex> alias MyApp.Orders.OrderNotFound
iex> error = Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: 42})
iex> error.reason
:not_found
iex> match?(%Errata.Env{}, error.env)
true

In a real module, write use Errata rather than require Errata — it does the same require and brings the guards into scope at the same time:

defmodule MyApp.Orders do
use Errata
alias MyApp.Orders.OrderNotFound
alias MyApp.Orders.PaymentDeclined
def find(id) do
{:error, Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: id})}
end
def pay(_order) do
{:error, Errata.create(PaymentDeclined, reason: :insufficient_funds)}
end
end

create/1 on the error module does exactly the same thing, and reads a little more directly when a module works mostly with one error type. It is a macro on the error module, so that module must be required:

iex> require MyApp.Orders.OrderNotFound, as: OrderNotFound
iex> error = OrderNotFound.create(reason: :not_found, context: %{order_id: 42})
iex> error.reason == :not_found
true
iex> error.context == %{order_id: 42}
true
iex> match?(%Errata.Env{stacktrace: stacktrace} when is_list(stacktrace), error.env)
true

new/1 is a plain function that builds the error without environment info:

iex> alias MyApp.Orders.OrderNotFound
iex> OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
%OrderNotFound{reason: :not_found, context: %{order_id: 42}, env: nil}

Which should I use? {: .tip}

Use Errata.create/2 — or create/1 if you have required the error module — unless you have a reason not to. The module, function, file, line, and stacktrace of an error's origin are often the most useful things you have when debugging, and capturing them costs on the order of a microsecond, which is negligible next to almost any operation that can fail. Both are macros, which is what lets them see the call site at all.

new/1 is for the cases a macro cannot serve. It can be called dynamically — apply(OrderNotFound, :new, [params]) — where a macro raises UndefinedFunctionError, and it can be captured as &OrderNotFound.new/1 and passed around, where capturing a macro would freeze the environment of the capture site into every error it builds. It is also handy in tests and fixtures, where env: nil keeps error structs easy to compare.

However the error is created, wrap it in a tuple when returning it from a function:

{:error, Errata.create(OrderNotFound, reason: :not_found)}
{:error, OrderNotFound.create(reason: :not_found)}
{:error, OrderNotFound.new(reason: :not_found)}

Raising errors as exceptions

Because Errata errors are ordinary Elixir exceptions, the same type can also be raised with raise/2, passing params as the second argument:

raise MyApp.Orders.OrderNotFound, reason: :not_found, context: %{order_id: 42}

Guides

The sections above are the whole of what most applications need. The guides cover the rest, and follow the life of an error — handled, composed as it travels, converted where it leaves, reported:

Installation

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

def deps do
[
{:errata, "~> 1.5"}
]
end

JSON encoding

Errata encodes errors to JSON through whichever backend is available, so you generally don't need to configure anything:

Both backends produce the same JSON shape. If neither is available (Elixir older than 1.18 without Jason), errors can still be converted to a plain map with Errata.to_map/1, which you can encode however you like.

Documentation is generated with ExDoc and published on HexDocs.