Errors: model them once, catch them once#

Modeling errors in a useful, standard way is really important. An error has two goals:

  1. Tell the user (or the client) what is wrong, so they understand it and maybe can fix it themselves.
  2. 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
}
  • message is for the user, details mostly for you.
  • location is the field that saves you during debugging: it points to the exact line that created the error.
  • reason is a small, generic set (HTTP statuses work well), not a new atom per case: what went wrong goes in message and details.

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

  1. render it to the client;
  2. 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 details and location, also for internal errors.
  • It returns the error, so it fits in a pipeline.
  • You can also choose the level from the reason: :warning for 4xx (the client’s mistake), :error for 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: true error (a missing config, a failed call to an external service) is always logged, but the client gets only a generic 500 Internal Server Error.
  • details are never sent to the client: only reason and message.
  • A raised exception never reaches the fallback controller: Phoenix catches it, logs it, and renders the 500 template of ErrorJSON (set with render_errors in 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.
  • AppError is the JavaScript version of the Error struct: a class extending Error with statusCode, details and internal.
  • No macro needed: JavaScript captures the stack trace when the error is created (err.stack), and its first at line is the location. cause keeps the foreign error.

Ref: Error cause

Wrapping up#

  • One schema for all errors: reason, message for the user, details for you, location, an optional stacktrace, 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.