Errors: model them once, catch them once
Errors: model them once, catch them once#
Modeling errors in a useful, standard way is really important. An error has two goals:
- Tell the user (or the client) what is wrong, so they understand it and maybe can fix it themselves.
- Make debugging easy for you.
This post shows how to design errors for both goals: a schema, one way to propagate them, a few rules on where to catch them, and one place to log and render them. The main examples come from an Elixir/Phoenix backend, with a short JavaScript version at the end.
The schema#
Define one error shape for the whole project, with fields for both goals:
Error {
reason # generic category, e.g. the HTTP status: not_found, forbidden
message # fixed sentence for the user (and for you), with %{placeholders}
details # technical data for debugging; also the values for the placeholders
location # where the error was built: file, line, module, function
stacktrace # optional: the full call stack, only when asked (it's expensive)
internal # true → the client sees a generic error, without reason and message
}
messageis for the user,detailsmostly for you.locationis the field that saves you during debugging: it points to the exact line that created the error.reasonis a small, generic set (HTTP statuses work well), not a new atom per case: what went wrong goes inmessageanddetails.
In Elixir it’s a struct. Being an exception, it can also be raised:
defmodule MyApp.Errors.Error do
defexception [:reason, :location, :message, :stacktrace, details: %{}, internal: false]
def new(reason, %{} = opts \\ %{}) when is_atom(reason) do
%__MODULE__{
reason: reason,
location: Map.get(opts, :location),
message: Map.get(opts, :message),
details: Map.get(opts, :details, %{}),
internal: Map.get(opts, :internal, false),
stacktrace: if(Map.get(opts, :stacktrace), do: current_stacktrace())
}
end
defp current_stacktrace do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
stacktrace
end
end
One way to propagate errors#
Depending on the language you can raise errors or return them. Pick one way for your project and use it consistently.
In Elixir the convention is to return {:ok, result} or {:error, reason}, and to raise only for bugs (a broken invariant). With with, an error stops the chain and goes up as it is:
def import_products(file_id, auth) do
with {:ok, file} <- Storages.get_file(file_id, auth),
{:ok, rows} <- parse_csv(file) do
insert_products(rows)
end
end
No else: any {:error, _} is returned to the caller untouched.
The macro: capture the location#
Writing the location by hand is boring and gets out of date. If your language has macros, build errors with a macro that captures the call site at compile time:
defmodule MyApp.Errors do
alias MyApp.Errors.Error
defmacro new(reason, opts \\ quote(do: %{})) do
# {module, function, arity, [file: ..., line: ...]} of the caller
[location] = Macro.Env.stacktrace(__CALLER__)
quote do
Error.new(
unquote(reason),
Map.put(unquote(opts), :location, unquote(Macro.escape(location)))
)
end
end
end
Use it like a function:
defp fetch_storage_file(query, storage_file_id) do
case Repo.get(query, storage_file_id) do
nil ->
{:error,
Errors.new(:not_found, %{
message: "File %{storage_file_id} not found",
details: %{storage_file_id: storage_file_id}
})}
file ->
{:ok, file}
end
end
- The location is filled automatically:
lib/my_app/storages.ex:415 MyApp.Storages.fetch_storage_file/2. - It’s resolved at compile time: no runtime cost, no stack trace to walk.
- Even similar errors (two “File not found” in different modules) are easy to tell apart: the location says which line built each one.
- Without macros, capture the stack trace when the error is created (see the JavaScript version). It costs more at runtime but gives the same information.
When the location is not enough#
The location tells you where the error was built, not who called that code. If fetch_storage_file/2 is called from ten places, you don’t know which call failed.
For these errors, ask for the stack trace too:
Errors.new(:not_found, %{
message: "File %{storage_file_id} not found",
details: %{storage_file_id: storage_file_id},
stacktrace: true
})
- Keep it optional: reading the stack has a runtime cost, and most errors don’t need it.
- Use it for errors built in shared helpers, where the caller matters.
- On the BEAM the stack trace can miss some frames: tail calls leave no trace.
The rules: when to catch an error#
Don’t catch an error you can’t handle#
And no, logging it doesn’t count as handling it. Catching an error just to skip it, without even logging it, is the worst case: the failure disappears.
Wrong#
def import_products(file_id, auth) do
case Storages.get_file(file_id, auth) do
{:ok, file} ->
parse_csv(file)
{:error, reason} ->
Logger.error("cannot get the file: #{inspect(reason)}")
{:error, reason}
end
end
The same in JavaScript: operation().catch((e) => { console.log(e); return e; }).
- The error is logged here, and again by whoever handles it up the chain: two log lines for one failure.
- More code that does nothing useful.
- Even worse:
{:error, _} -> {:ok, []}, the operation “succeeds” and nobody knows it failed.
Right#
def import_products(file_id, auth) do
with {:ok, file} <- Storages.get_file(file_id, auth) do
parse_csv(file)
end
end
The error goes up untouched, to the one place that can do something with it.
Catch it when you can turn it into a success#
When there is a real alternative, handle the error and return a success. Log it as a warning if the degradation matters:
case Cache.get(key) do
{:ok, value} ->
{:ok, value}
{:error, error} ->
Errors.log(error, :warning)
load_from_db(key)
end
Catch foreign errors and map them to your Error#
Libraries and external services have their own errors: exceptions, error tuples, HTTP responses. Catch them at the boundary, where your code calls them, and map them to your Error:
def fetch_shop(shop_domain, token) do
case Req.get("https://#{shop_domain}/admin/api/2026-07/shop.json", auth: {:bearer, token}) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body["shop"]}
{:ok, %Req.Response{status: status, body: body}} ->
{:error,
Errors.new(:internal_server_error, %{
message: "Shopify request failed",
details: %{status: status, body: body},
internal: true
})}
{:error, exception} ->
{:error,
Errors.new(:internal_server_error, %{
message: "Shopify request failed",
details: %{exception: Exception.message(exception)},
internal: true
})}
end
end
- Above the boundary, the code sees only your
Error: one shape to log and render. - The foreign error is kept in
details, so nothing is lost for debugging. - The location points to your call to the service, not to some line deep inside the library.
Catch it at the root of the flow#
The root is the end of the chain, where the result goes back to the user or to its destination. There you must catch the error and:
- render it to the client;
- log it (sometimes optional, e.g. for expected 4xx errors).
Examples of roots:
- the web/API controller that renders the response;
- a background job that stores its result somewhere (e.g. marks a record as failed).
The root also needs a catch-all for everything that is not your Error: an unexpected exception, a value you didn’t map. Log it and answer with a generic error.
Logging and rendering: one function each#
Write one standard function per output: error → log, error → JSON response, and any other rendering you need. Every root calls them, so every error looks the same everywhere.
The log function#
def log(%Error{} = error, level \\ :error) do
Logger.log(level,
error: error.reason,
message: Error.format_message(error),
location: Error.format_location(error),
details: error.details,
stacktrace: error.stacktrace && Exception.format_stacktrace(error.stacktrace)
)
error
end
- It logs every field, including
detailsandlocation, also for internal errors. - It returns the error, so it fits in a pipeline.
- You can also choose the level from the reason:
:warningfor 4xx (the client’s mistake),:errorfor the rest.
Rendering in the controller#
In Phoenix, a controller returns {:error, error} and the fallback controller renders it. It’s the only place where errors reach the client:
defmodule MyAppWeb.FileController do
use MyAppWeb, :controller
action_fallback MyAppWeb.FallbackController
def show(conn, %{"id" => id}) do
with {:ok, file} <- Storages.get_file(id, conn.assigns.auth) do
render(conn, :show, file: file)
end
end
end
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
require Logger
alias MyApp.Errors
alias MyApp.Errors.Error
# Internal: log everything, render only the generic status message
def call(conn, {:error, %Error{internal: true} = error}) do
Errors.log(error)
code = Plug.Conn.Status.code(error.reason)
conn
|> put_status(code)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:"#{code}")
end
# Public: log everything, render reason and message
def call(conn, {:error, %Error{} = error}) do
Errors.log(error)
conn
|> put_status(error.reason)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:error, reason: error.reason, message: error.message, details: error.details)
end
# Catch-all: anything that is not an Error, log it and render a generic 500
def call(conn, other) do
Logger.error(unexpected_error: inspect(other))
conn
|> put_status(:internal_server_error)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:"500")
end
end
- The controller action has no error handling: only the happy path.
- Public or internal: an
internal: trueerror (a missing config, a failed call to an external service) is always logged, but the client gets only a generic500 Internal Server Error. detailsare never sent to the client: onlyreasonandmessage.- A raised exception never reaches the fallback controller: Phoenix catches it, logs it, and renders the
500template ofErrorJSON(set withrender_errorsin the endpoint config). That’s the catch-all for exceptions.
Localized messages#
The message is a fixed sentence, so it can be translated. The details are the bindings for its placeholders:
defmodule MyAppWeb.ErrorJSON do
def error(%{reason: reason, message: message, details: details}) do
%{errors: %{reason: reason, message: translate(message, details)}}
end
# Generic body for a status: "Not Found", "Internal Server Error"
def render(template, _assigns) do
%{errors: %{message: Phoenix.Controller.status_message_from_template(template)}}
end
defp translate(message, bindings),
do: Gettext.dgettext(MyAppWeb.Gettext, "errors", message, bindings)
end
{ "errors": { "reason": "not_found", "message": "File 42 non trovato" } }
- Never interpolate a value into the message (
"File #{id} not found"): the string no longer matches its translation. - A detail reaches the client only when the message names it in a placeholder.
Ref: Phoenix action_fallback, Gettext interpolation
The same in JavaScript#
In JavaScript the idiomatic choice is to throw (or reject). The example uses a made-up web framework: most of them have an error handler like this one. The route handler has only the happy path: an error anywhere in the chain skips the next steps and reaches the error handler, the root.
function routeHandler(req, res) {
return something(req, res)
.then(opA)
.then(opB)
.then(opC);
}
app.get('/things/:id', routeHandler);
app.onError((err, req, res) => {
// The one function that logs errors: every field of an AppError, or the unexpected error
logError(err);
if (err instanceof AppError && !err.internal) {
return res.status(err.statusCode).json({ error: { message: err.message } });
}
// Catch-all: internal AppError or unexpected exception
return res.status(500).json({ error: { message: 'Internal Server Error' } });
});
- No
.catch()in the handler: the error goes straight to the error handler. AppErroris the JavaScript version of theErrorstruct: a class extendingErrorwithstatusCode,detailsandinternal.- No macro needed: JavaScript captures the stack trace when the error is created (
err.stack), and its firstatline is the location.causekeeps the foreign error.
Ref: Error cause
Wrapping up#
- One schema for all errors:
reason,messagefor the user,detailsfor you,location, an optionalstacktrace,internal. - One way to propagate: return or raise, and stick to it.
- Catch an error only when you can handle it: turn it into a success, map a foreign error to your
Error, or at the root (controller, job), where you render and log it. - A catch-all at the root: anything unexpected is logged and answered with a generic error.
- One function to log, one to render: every error is logged once, with the same fields.
- The code stays clean: errors bubble up from the bottom to the one place that handles them, with no
.catch(console.log)along the way. - The location macro shows where every error comes from: debugging starts from the right line of code.