Advice on structuring error return values

Hey,

in my App, I have to catch and collect errors to report them to the user and now it gets a little bit complicated.

First, here are some example modules that represent my setup.

defmodule MyApp do
  def index(path) do
    MyApp.Indexer.index(path)
  end

  def another_task(args) do
    # implementation
  end

  def another_task_two(args) do
    # implementation
  end
end
defmodule MyApp.Indexer do
  def index(path) do
    with :ok <- check_required_files(path),
         {:ok, config} <- load_config(path),
         ... <- ...,
         ... <- ... do
       # building result struct
       {:ok, result}
    end
  end

  ... # rest of the module with the functions shown above
end

And in my app entrypoint, I have the following.

with {:ok, index_result} <- MyApp.index(path),
      :ok <- MyApp.another_task(index_result),
      {:ok, result} <- MyApp.another_task_two(index_result) do
  # handling result
else
# handling errors
end

Ok, here comes the tricky part. In the final entrypoint code, I want to differentiate the errors so I can generate the correct output for the user. All my functions should return error tuples.

The first problem here is that I got 3 tasks (index, another_task, another_task_two) and I want to know which one failed. Someone suggested wrapping them inside special tuples for the with call, something along the lines of

with {:indexing, {:ok, index_result}} <- {:indexing, MyApp.index(path)}

so I can later pattern match on {:indexing, {:error, reason}} in the else block which is an acceptable solution.

But there is more. I gave a concrete example with the index task. I want that to return not just only one error, but if needed, a list of errors.
My example gives two subtasks of the index task. Checking for required files and loading a config file (which includes validation of that file).

So lets say, that not all required files are present, then I want to return a list of errors, each error saying which file is missing. But I also need the information that the errors belong to the task of checking for required files so I can later generate output that looks like:

An error occured. Some required files are missing:
- list
- of
- files

The same concept applies to the config loading and validation. I want to know that the config validation failed and all the reasons why it failed (which required fields are missing or have invalid values).

Also note, that I dont need to combine those two usecases. If the check for required files failed, its fine that the with aborts and only returns the errors for the required files.

Now I have a hard time modeling my error return values. Should I create an error struct for each error (e.g. RequiredFileMissing and ConfigKeyValidationError) plus some sort of ErrorBag that can hold a list of errors and has a context field? I mean, that sounds useful but is that very Elixir’ish?

Any other ideas?

2 Likes

@Phillipp Here are formats I have used and I think that they are best in their own cases:

  1. {:error, reason} when reason it atom - simplest error with unique error code
  2. {:error, reasons} when reasons is list of atom - it’s not used very often, but sometimes it’s just neede
  3. {:error, custom_error} when custom_error is your own struct - useful if error codes are not unique and/or when you need to save store some custom data for error
  4. {:error, custom_erros} similar to point 2, but for custom_error

I recommend you to use 3rd or 4th point (depends on your needs). Here is example code:

defmodule MyApp.Error do
  @typedoc "Non-unique error code. Useful when used with `group`"
  @typep code :: atom

  @typedoc "An unique identifier of errors group"
  @typep group :: atom

  @typedoc "Represents required data to explain my app errors"
  @type t :: %{code: code, data: integer, group: group, message: String.t}

  def set_message(%__MODULE__{code: code, data: data, group: group} = map) do 
    message = do_set_message(group, code, data)
    %{map | message: message}
  end

  defp do_set_message(:parse, :json, %{at: at, file: file, info: info, line: line}),
    do: "[JSON PARSE ERROR] #{info} found in #{file}:#{line} at #{at}"
end

defmodule MyApp.Example do
  alias MyApp.{Error, JSON}

  def sample(json), do: json |> JSON.parse() |> do_sample()

  defp do_sample({:ok, _data} = result), do: result
  defp do_sample({:error, data}), do: {:error, %Error{code: :json, data: data, group: :parse}}
end

defmodule MyApp do
  alias __MODULE__.{Error, Example}

  def run([json]), do: json |> Example.sample() |> do_run()

  defp do_run({:ok, data}), do: data

  defp do_run({:error, error}) do
    error_with_message = Error.set_message(error)
    IO.inspect(error_with_message)
  end
end

I have used {:error, Exception.t()} (and {:error, [Exception.t()]}) in similar situations. These are @Eiji’s points 3 & 4, respectively, but using the standard exception type rather than a totally custom struct.

1 Like

Yep, or even just returning the value or the Exception directly is easy as well since you can trivially match on an exception (%{__exception__: true}) to know if it’s the exception or the value, and it makes it very easy to build a pipeline on that, passing ‘through’ the exception or passing the value ‘into’, this is the way the exceptional library works. :slight_smile: