View Source Errata (Errata v0.9.0)
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.
Each Errata error is an Exception struct with a well-defined set of fields:
message— a human-readable description of the errorreason— an atom that classifies the error, useful for pattern matchingcontext— a map of arbitrary metadata captured at the site of the errorenv— the module, function, file, line, and stacktrace where the error was created (seeErrata.Env)
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:
- Define custom error types in one line with
use Errata.DomainError,use Errata.InfrastructureError, oruse Errata.Error. - Use an error as a value or an exception — the same type can be returned
in an
{:error, error}tuple or raised withraise/2. - Capture rich context — an error reason, arbitrary metadata, and the exact point of origin (module, function, file, line, and stacktrace).
- Classify errors as domain, infrastructure, or general, and branch on
that classification at system boundaries with the
Errataguards. - Serialize errors automatically — every error type implements the
String.CharsandJason.Encoderprotocols.
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
endAn 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, which places it into one of three classifications:
- Domain errors represent error conditions within a problem domain or bounded
context. These are business-process violations or other errors in the problem
domain, and so should be part of the
Ubiquitous Language
of the domain. Define them with
Errata.DomainError. - Infrastructure errors represent errors that occur at an infrastructure level
but are not part of the problem domain, such as network timeouts, database
connection failures, or filesystem errors. Define them with
Errata.InfrastructureError. - General errors are errors that fit neither category, such as errors that
emanate from library code, or any error for which the distinction does not
matter. Define them with the base
Errata.Error.
An error's kind is primarily a concern at the boundaries of the system rather
than within domain logic. Code at the edges of the application (such as a
Phoenix fallback controller) can branch on an error's kind using the
custom guards — translating domain errors into 4xx
responses that are safe to show users, and infrastructure errors into 5xx
responses that are logged with alerting and hidden from users. Within your
domain logic, by contrast, you generally dispatch on the specific error type.
In short: an error's kind decides how the boundary treats it, while its
type decides how your domain logic behaves.
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
endFor 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
endEach use accepts a few options:
:default_message— the:messageto use when none is given:default_reason— the:reasonto use when none is given
Whichever module you use, the resulting error type is an exception struct that
conforms to the 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 two ways to create an error. new/1 builds an error from the given
params but leaves the :env field nil:
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}create/1 additionally captures the current __ENV__ and stacktrace into the
:env field. Because it is a macro, the error module must be required first:
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)
truePrefer
create/1to capture contextBecause
new/1leaves the:envfieldnil, it discards the module, function, file, line, and stacktrace of the error's origin — often the most useful information when debugging or reporting an error. Prefercreate/1(orErrata.create/2, below) unless you have a specific reason not to capture this context.
The create/1 macro must be required for each error module. As an
alternative, the Errata.create/2 macro creates an error of any type without
a separate require for each one — convenient when a module works with several
error types. Since you typically already require Errata to use the custom
guards, you can simply alias your error modules and call Errata.create/2:
iex> require Errata
iex> alias MyApp.Orders.OrderNotFound
iex> error = Errata.create(OrderNotFound, reason: :not_found)
iex> error.reason
:not_found
iex> match?(%Errata.Env{}, error.env)
trueHowever the error is created, wrap it in a tuple when returning it from a function:
{:error, OrderNotFound.new(reason: :not_found)}
{:error, OrderNotFound.create(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}Handling errors
Errata errors are standard Elixir exceptions, so they can be rescued like any
other exception, and Kernel.is_exception/1 returns true for them. In
addition, Errata provides guards for recognizing and classifying its errors:
Errata.is_error/1— true for any Errata errorErrata.is_domain_error/1— true for domain errorsErrata.is_infrastructure_error/1— true for infrastructure errors
To use these guards, import or require the Errata module. The kind-based
guards are especially useful at system boundaries — for example, translating
domain errors into client errors (4xx) and infrastructure errors into server
errors (5xx) with alerting — while domain logic generally matches on the
specific error type.
The following example handles Errata errors both as raised exceptions and as error values returned from functions:
rescueclauses and the custom guardsElixir's
rescueclauses only accept a bare variable or thevar in [ExceptionModule]form; they do not accept arbitrarywhenguards. To use theErrata.is_error/1family when rescuing, rescue the exception into a variable and then dispatch on it (for example withcond/1), as shown below. The guards can be used directly in thewhenclause of acase,with, or function head when handling errors returned as values.
defmodule MyApp.Orders.Boundary do
# require the Errata module to use the custom guards
require Errata
def handle_order_lookup_as_exception(id) do
try do
MyApp.Orders.fetch_order!(id)
rescue
e in [MyApp.Orders.OrderNotFound] ->
# Errata errors can be rescued by their specific type
handle_order_not_found(e)
e ->
# `rescue` clauses cannot use `when` guards, so rescue the exception
# and then dispatch on it using the custom guards defined in the
# Errata module
cond do
Errata.is_error(e) -> handle_errata_error(e)
# Regular exceptions may be handled separately if desired
true -> handle_other_error(e)
end
end
end
def handle_order_lookup_as_value(id) do
case MyApp.Orders.fetch_order(id) do
{:ok, order} ->
handle_order(order)
{:error, %MyApp.Orders.OrderNotFound{} = error} ->
# Errata errors can be pattern matched by their specific type
handle_order_not_found(error)
{:error, error} when Errata.is_error(error) ->
# Or they can be identified using one of the custom guards defined in
# the Errata module (`when` guards are allowed in `case` clauses)
handle_errata_error(error)
{:error, reason} ->
# Other errors may be handled separately if desired
handle_other_error(reason)
end
end
endThe patterns above, distilled into runnable examples — first, rescuing an exception and dispatching on it with the custom guards:
iex> require Errata
iex> alias MyApp.Orders.{OrderNotFound, PaymentDeclined}
iex> try do
...> raise OrderNotFound, reason: :not_found
...> rescue
...> e in [PaymentDeclined] ->
...> {:specific, e.reason}
...>
...> e ->
...> if Errata.is_error(e), do: {:errata, e.reason}, else: {:other, e}
...> end
{:errata, :not_found}And second, matching on an error returned as a value, where the guards can be
used directly in a when clause:
iex> require Errata
iex> alias MyApp.Orders.OrderNotFound
iex> case {:error, OrderNotFound.new(reason: :not_found)} do
...> {:error, e} when Errata.is_error(e) -> {:errata, e.reason}
...> {:error, other} -> {:other, other}
...> end
{:errata, :not_found}Rendering an error for users
Exception.message/1 (and the String.Chars implementation) return a
developer-oriented message that combines the :message and :reason (for
example, "the requested order does not exist: :not_found") — useful in logs
and raised-exception output. When rendering an error for an end user, use
Errata.display_message/1 instead, which returns just the human-readable
:message.
Choosing between an error type and a reason
Errata errors carry both a type (the module) and an optional :reason atom,
and it is not always obvious which to reach for. As a rule of thumb:
- Use a distinct error type for each condition that callers may want to handle differently or that has its own meaning in the domain. The type is the primary identity of an error and the thing you pattern match on.
- Use the
:reasonfield to sub-classify within a single error type — to distinguish variations of the same error that share handling but differ in cause.
For example, a single PaymentDeclined domain error can use :reason to record
why the payment was declined, rather than defining a separate type for each
cause:
PaymentDeclined.create(reason: :insufficient_funds)
PaymentDeclined.create(reason: :fraud_suspected)Conversely, a :reason that merely restates the type name (such as
OrderNotFound.create(reason: :order_not_found)) adds no information and can be
omitted.
Why Errata?
It is common in Elixir and Erlang to signal failure with an error tuple of the
form {:error, reason}. All too often, though, the reason is a bare atom or
(worse) a string that carries no context: it may read clearly enough in the
surrounding code, but as a log message or error report — far from where the
error arose — it lacks the detail needed to interpret what actually happened.
Raising exceptions is a less common but still widespread alternative. Exceptions do carry some context, including a stacktrace, but they lack a common, uniform structure to build logging and error handling around.
Errata gives all errors a uniform structure and lets them be created with full contextual detail, including arbitrary metadata. That context is embedded in the error struct, so it propagates with the error whether the error is raised or returned as a value, and the error is JSON-encodable so it can be reported to an external service such as Sentry.
This pays off, in particular, in with expressions. When each step returns
{:ok, result} or {:error, reason} and the reason lacks context, the with
is forced to add an else clause to log or report every possible error
meaningfully. When each error is instead a structured type carrying its own
context, the with can omit the else clause entirely and let the error
propagate to a boundary — such as a Phoenix controller — where it is logged or
reported without any loss of the context needed to interpret it.
Chris Keathley discusses this point in depth in his blog post
Good and Bad Elixir, under
"Avoid else in with blocks".
Summary
Types
Type to represent Errata domain errors.
Type to represent any kind of Errata error.
Type to represent the various kinds of Errata errors.
Type to represent Errata infrastructure errors.
Functions
Creates an error of the given error_module, capturing the current __ENV__
and stacktrace into the :env field.
Returns the human-readable display message for an error: the value of its
:message field, or nil if none was set.
Returns true if term is an Errata domain error type; otherwise returns false.
Returns true if term is any Errata error type; otherwise returns false.
Returns true if term is an Errata infrastructure error type; otherwise returns false.
Converts any Errata error to a plain, JSON-encodable map.
Types
@type domain_error() :: %{ __struct__: module(), __exception__: true, __errata_error__: true, kind: :domain, message: String.t() | nil, reason: atom() | nil, context: map() | nil, env: Errata.Env.t() | nil }
Type to represent Errata domain errors.
@type error() :: %{ __struct__: module(), __exception__: true, __errata_error__: true, kind: error_kind(), message: String.t() | nil, reason: atom() | nil, context: map() | nil, env: Errata.Env.t() }
Type to represent any kind of Errata error.
Errata errors are Exception structs that have additional fields to contain extra contextual
information, such as an error reason or details about the context in which the error occurred.
@type error_kind() :: :domain | :infrastructure | :general | nil
Type to represent the various kinds of Errata errors.
@type infrastructure_error() :: %{ __struct__: module(), __exception__: true, __errata_error__: true, kind: :infrastructure, message: String.t() | nil, reason: atom() | nil, context: map() | nil, env: Errata.Env.t() | nil }
Type to represent Errata infrastructure errors.
Functions
Creates an error of the given error_module, capturing the current __ENV__
and stacktrace into the :env field.
This is a convenience equivalent to the per-module Errata.Error.create/1
macro, but it lives on the Errata module. Because you typically already
require Errata (to use the guards above), you can alias your error modules
and call Errata.create/2 for any of them without a separate require for
each error type:
defmodule MyApp.Orders do
require Errata
alias MyApp.Orders.{OrderNotFound, PaymentDeclined}
def fetch_order(id) do
{:error, Errata.create(OrderNotFound, reason: :not_found, context: %{order_id: id})}
end
endCompare to the per-module macro, which requires a require for every error
type used in the module:
require MyApp.Orders.OrderNotFound, as: OrderNotFound
require MyApp.Orders.PaymentDeclined, as: PaymentDeclined
Returns the human-readable display message for an error: the value of its
:message field, or nil if none was set.
This is distinct from Exception.message/1 (and the String.Chars
implementation), which return a developer-oriented message that also
includes the :reason — useful in logs and raised-exception output, but not
intended for end users. Use display_message/1 when rendering an error for a
user (for example, the body of a 4xx HTTP response), supplying your own
fallback for the nil case.
iex> alias MyApp.Orders.PaymentDeclined
iex> error = PaymentDeclined.new(reason: :insufficient_funds)
iex> Errata.display_message(error)
"the payment was declined"
iex> Exception.message(error)
"the payment was declined: :insufficient_funds"Raises an ArgumentError if error is not an Errata error.
Returns true if term is an Errata domain error type; otherwise returns false.
Allowed in guard tests.
Returns true if term is any Errata error type; otherwise returns false.
Allowed in guard tests.
Returns true if term is an Errata infrastructure error type; otherwise returns false.
Allowed in guard tests.
Converts any Errata error to a plain, JSON-encodable map.
This is the generic counterpart to the per-type Errata.Error.to_map/1
callback: it works on any value for which is_error/1 returns true,
without needing to know the error's specific module. This is convenient at
system boundaries (such as a Phoenix fallback controller) where errors of
many different types are handled uniformly.
iex> alias MyApp.Orders.OrderNotFound
iex> error = OrderNotFound.new(reason: :not_found, context: %{order_id: 42})
iex> map = Errata.to_map(error)
iex> map.error_type
"MyApp.Orders.OrderNotFound"
iex> map.reason
:not_found
iex> map.context
%{order_id: 42}Raises an ArgumentError if error is not an Errata error.