DEV Community

Leandro Cesquini Pereira
Leandro Cesquini Pereira

Posted on • Originally published at leandrocp.com.br on

Leveraging Exceptions to handle errors in Elixir

Returning a tagged tuple {:ok, result} | {:error, reason} is the de facto practice to handle errors in Elixir, but that may not be enough for all situations and this article will explore how to leverage Exceptions to enrich errors and the benefits of doing so.

Looks like that subject is a recurring source of discussion, as Martin Gausby asked the community how to deal with expected and unexpected errors, and turns out Michał Muskała has already introduced a clever technique to handle errors that was used by Andrea Leopardi on libraries Mint and Redix. He tweeted defending that exceptions are a great return value, so let’s dig in to find out how that works.

TLDR

  • Encapsulate all possible errors of a function into a custom Exception;
  • Leverage Exceptions message mechanism to avoid coupling;
  • Let the caller decide if the error is expected or not;

Code example:

Error exception lib/my_app/error.ex

defmodule MyApp.Error do
  @type t() :: % __MODULE__ {
          module: module(),
          reason: atom(),
          changeset: Ecto.Changeset.t() | nil
        }

  defexception [:module, :reason, :changeset]

  @spec wrap(module(), atom()) :: t()
  def wrap(module, reason), do: % __MODULE__ {module: module, reason: reason}

  @spec wrap(module(), atom(), Ecto.Changeset.t()) :: t()
  def wrap(module, reason, changeset) do
    % __MODULE__ {module: module, reason: reason, changeset: changeset}
  end

  @doc """
  Return the message for the given error.

  ### Examples

       iex> {:error, %MyApp.Error{} = error} = do_something()
       iex> Exception.message(error)
       "Unable to perform this action."

  """
  @spec message(t()) :: String.t()
  def message(% __MODULE__ {reason: reason, module: module}) do
    module.format_error(reason)
  end
end

Context lib/my_app/accounts.ex

defmodule MyApp.Accounts do
  @spec register(map()) :: {:ok, User.t()} | {:error, MyApp.Error.t()}
  def register(attrs) do
    # simulate a function that may return more than one type of error
    case has_permission?(attrs) do
      true ->
        # simulate that something wrong happened on register,
        # and note that changeset is just a regular changeset
        MyApp.Error.wrap( __MODULE__ , :register, changeset)

      false ->
        # another situation requires another type of error
        MyApp.Error.wrap( __MODULE__ , :insufficient_permisions)
    end 

    # and other errors could happen...
  end

  # translate the error case into a friendly message
  def format_error(:register), do: "Unable to register account."
  def format_error(:insufficient_permissions), do: "Unable to perform action due to insufficient permissions."
end

Caller LiveView, Controller, etc

case MyApp.Accounts.register(attrs) do
  {:ok, user} ->
    continue_happy_path()

  {:error, error} ->
    socket =
      socket
      |> put_flash(:error, Exception.message(error))
      |> assign(:changeset, error.changeset)

    {:noreply, socket}
end

You may be asking, why not just return {:error, :reason} or even {:error, "message"}? First of all, try to avoid returning a string because that will complicate the pattern matching and a simple change will break your system, on the other hand returning an atom is totally fine when your function doesn’t need to deal with different errors and messages. But usually context or complex functions has more outcomes than just a single possible error, and besides that they’re usually consumed by another layer that needs to transform that error into useful feedback for the user.

Some practical scenarios and benefits

  • In LiveViews or Controllers, you usually need to display a flash message to let the user know what’s happening. If you return {:error, atom()} that means you need to pattern match all possible atoms to create the proper message. With exceptions, all you need to do is to implement format_error in a single place and call Exception.message(error).
  • If that same function changes current atom or adds another return value, you have to go through all places where that function is called to update the pattern match. That’s error-prone as you may miss something and the compiler won’t help here
  • Shared and reused messages. Think about generic errors such as authorization, system errors, and others. Those errors produce the same message everywhere, which requires duplicating the pattern match and the message definition in different places. That can be solved by relying on the expcetion message mechanism.
  • Cohesion. Implementing a format_message/1 close to where the error happens will improve maintainability.
  • Transparency. By having the module and a reason in the error struct, you’ll have the origin of that error, from where it’s coming from. Stack traces won’t help when the error is expected and it’s not raised.

But what about that changeset in the middle of the error?

Regular apps, especially web apps, depends a lot on Changesets to return feedback to users but also has to deal with complementary errors on more complex scenarios where returning just an invalid changeset isn’t enough. Suppose a function that deals with form submission but also has to call an external service, check permissions, or deal with that crazy legacy rule. Many different errors or situations may happen: the changeset may be invalid, an external service may be offline, or maybe an error happened but the changeset is valid and needs to be updated to reflect changes on the template. That would require more complex return values like adding more values on the tagged tuple, a struct to store all values, or something. A better approach is to return either {:error, %MyApp.Error{reason: :invalid_input, changeset: changeset}}, {:error, %MyApp.Error{reason: :billing_service_offline}}, or whatever is needed. All you need will be encapsulated on the exception struct. With that return, you can either display inline errors on form if the changeset is valid, call Exception.message(error) to give proper feedback for the user or update the changeset while giving feedback for the user about another error. It’s very flexible and simple.

Error is part of your application

You’re not limited to a generic %MyApp.Error{reason: atom()}, in fact you can implement explicit errors like %MyApp.OfflineService{reason: atom(), status: integer()} instead of %MyApp.Error{reason: :billing_service_offline} to enrich errors specific to your app’s domain. Some benefits include leveraging pattern matching for control flow, explicit errors when raising or reading your code, and encapsulate metadata about specific errors, but not limited to those benefits.

Error is expected or not?

That depends on the caller because that usually is tied to the current situation. Suppose a function that calculates something based on a set of data, if that function is called by a Controller or LiveView probably the user is waiting for feedback, but if that function is called by an async process (mostly a background process) there’s no reason to present a message so raising to force the process to restart may be the best approach. In short, let the caller decide:

  • If the error is expected or needs to display feedback, call Exception.message(error)
  • If the error is not expected or there’s no way to recover from that, call raise error

Remember that error is in fact an exception, so raising it is simple as calling raise error, but remember to avoid using try/rescue for control flow and reserve that for situations where the function has reached the end of the line and there’s nothing else to do unless raising.

Notes

Thanks to:

Top comments (0)