Skip to content

Latest commit

 

History

History
125 lines (89 loc) · 7.03 KB

README.md

File metadata and controls

125 lines (89 loc) · 7.03 KB

AutoError

AutoError helps you to pipe between functions returning {:ok, _} or {:error, _} easily.

Installation

The package can be installed by adding auto_error to your list of dependencies in mix.exs:

def deps do
  [
    {:auto_error, "~> 0.1.0"}
  ]
end

Usage

import AutoError

AutoError is easy to use, it provides two new operators ~> and ~>>, ~> is called a chain and ~>> is called a functor, don't need to concern about the new concept. Let's learn by some examples.

Examples

  iex> {:ok, 1} ~>> fn x -> x + 1 end.()
  {:ok, 2}

  iex> {:error, 1} ~>> fn x -> x + 1 end.()
  {:error, 1}

  iex> {:ok, 1} ~> fn x -> x + 1 end.()
  2

  iex> {:error, 1} ~> fn x -> x + 1 end.()
  {:error, 1}

In this example, we pipe a value to an anonymous function which will add the value by one, if you pass it a value with :ok, it will add one, but if you pass :error, it will not call the anonymous function and just return the original value.

Let's explain in detail, the basic pattern is like parameter ~> func.

~> is a macro, when parameter is kind of {:ok, value}, it will extract value and pass it to func like func(value), when parameter is kind of {:error, value}, ~> will not call func and just return the {:error, value} without modification.

~>> is also a macro, when parameter is kind of {:error, value}, ~>> behaves the same as ~>, when parameter is kind of {:ok, value}, it will call func as ~> does, but in the end, it will package the value into a new {:ok, value} format, then you can pipe the result to another function.

Seems interesting, but how this can help us to simplify error handling? Let's see a more complex example.

  iex> {:ok, 1} ~>> (&(&1 + 1)).() ~>> (&(&1 + 1)).() ~>> (&(&1 + 1)).()
  {:ok, 4}

  iex> {:ok, 1} ~> (&({:error, "Whoops:#{&1}"})).() ~>> (&(&1 + 1)).() ~> (&(&1 + 1)).()
  {:error, "Whoops:1"}

  iex> {:ok, 1} ~>> (&(&1 + 1)).() ~> (&({:error, "Whoops:#{&1}"})).() ~>> 
  ...> (&(&1 + 1)).() ~> (&(&1 + 1)).()
  {:error, "Whoops:2"}

  iex> {:ok, 1} ~>> (&(&1 + 1)).() |> IO.inspect() ~> (&({:error, "Whoops:#{&1}"})).() ~>> 
  ...> (&(&1 + 1)).() |> IO.inspect() ~>> (&(&1 + 1)).()
  {:ok, 2}
  {:error, "Whoops:2"}
  {:error, "Whoops:2"}

There are two kinds of functions: non-deterministic function and deterministic function

  • non-deterministic function: this function may succeed or fail. For example, a network request or user authentication will generate a new {:ok, _} or {:error, _}
  • deterministic function: this function will guarantee to succeed. For example, the add_one function in the above example, it just returns the bare result.

Now, let's explain the examples one by one:

  1. the first example is simple, we pass {:ok, 1} to three add_one function one by one, because add_one function won't encapsulate the result into a new {:ok, _} struct, so we use ~>> to package the result into {:ok, _} struct to continue processing.
  2. in the second example, we replaced the first add_one function with a function that returns {:error, err_msg}, in err_msg we record the value when the error happens. As we can see, the final result is {:error, "Whoops:1"}, means that the following two add_one function doesn’t run. If you check the code carefully, you may notice that for the last add_one function, I use ~> in the pipe, this doesn't have any special meanings, because there is an error before this pipe, so ~> and '~>>' will get the same result.
  3. for the third example, we add a add_one function before return {:error, _}, so the final result changes to {:error, "Whoops:2"}, because the first add_one runs.
  4. This example seems interesting, we combine ~>>, ~> and |> in the same pipeline. After first add_one function, we will get {:ok, 2}, then we pipe it to IO.inspect(), next we return an error and record the current value, so we get {:error, "Whoops:2"}, we won't run add_one for the value because it is already an error, but wait, why it prints another {:error, "Whoops:2"}, because the second IO.inspect() is controller by "|>", no matter what values it is, it will always run the function. This behavior is different with the With Syntax which will return immediately when the error happens, with AutoError, you can observe the pipeline anywhere, whether success or failure.

Advanced Examples

Sometimes, functions will raise exceptions. I know the art of Let it crash. But when processing some complex workflow as a unit, we can't just crash it, we need to try to recover and deliver the result as much as we can. So in AutoError, we capture the exception and report as {:error, exception}.

  iex> {:ok, 1} ~>> fn _ ->raise("network error") end.() ~>> (&(&1 + 1)).()
  {:error, %RuntimeError{message: "network error"}}

Why using AutoError

There will be a lot of errors in a production environment, which we need to handle carefully. Even though we can handle error processing with Case, Cond, Railway Oriented Programming or With Syntax. But they all bring some boilerplate code, that will confuse our eyes when we want to check the workflow or leave some errors unhandled by mistake.

To simplify error handling in Elixir, we can borrow some ideas from Monad. There is already a Monad library in Elixir called WitchCraft, but there are some disadvantages when using it.

  1. This library has many dependencies which will add compiling time and code complexity.
  2. It uses %Left{} and %Right{} to handle error, which conflicts with the {:ok, _} and {:error, _} idiom in Elixir ecosystem.
  3. Doesn't support passing first function's result as next function's first argument just as what |> does, so you must write %Algae.Either.Right{right: 1} >>> fn x -> IO.inspect(x) end instead of using {:ok, 1} ~> IO.inspect(),
  4. Bring in some warnings when using with Credo.
  5. Doesn't work well with mix format, actually I think this is a bug in mix format, because it can format both ~> and ~>>, but can't recognize >>> which is also a valid operator in Elixir.
  6. Hard to learn, WitchCraft supports more concept in Monad, and it even brings in a new TypeClass, if you want to use a lot of Monad related operations, this is great, but if you just want to solve error handling, this seems overhead, you need to learn a lot before you start.

Thanks

Special thanks to Falood, he helps me a lot when designing this library.