Skip to content

Latest commit

History

60 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Exceptional: Helpers for Elixir exceptions

Build StatusInline docshex.pm versionAPI Docslicense

Table of Contents

Installation

Add exceptional to your list of dependencies in mix.exs:

defdepsdo[{:exceptional,"~> 2.1"}]end

About

Exceptional is an Elixir library providing helpers for working with exceptions. It aims to make working with plain old (unwrapped) Elixir values more convenient, and to give full control back to calling functions.

See the Medium article for more

Prior Art

Tagged Status

The tagged status pattern ({:ok, _}, {:error, _}, etc)has been the bread and butter of Erlang since the beginning. While this makes it very easy to track the meaning of an expression, two things can happen:

  1. The tag becomes out of sync
  • ex. {:ok, "and yet not ok"}
  1. Pattern matching becomes challenging when different lengths exist
  • ex. {:error, "oopsie"}, {:error, "oopsie", %{original: :data, for: "handling"}}

Optimistic Flow

The other alternative is to be optimistic returns, generally seen with bang patterns. Ex. doc = File.read! path instead of {:ok, doc} = File.read path". This is more convenient, but will raise, robbing the caller of control without try/catch.

Error Monad

Currently a very undersused pattern in the Erlang/Elixir ecosystem, this is probably "the right way" to do general error handling (or at last the most theoretically pure one). Essentially, wrap your computation in an ADT struct, paired with a binding function (super-powered |>), that escapes the pipe flow if it encounters an Exception.

The downside is of course that people are generally afraid of introducing monads into their Elixir code, as understanding it requires some theoretical understanding.

Exceptional

Exceptional takes a hybrid approach. The aim is to behave similar to an error monad, but in a more Elixir-y way. This is less powerful than the monad solution, but simpler to understand fully, and cleaner than optimistic flow, and arguably more convenient than the classic tagged status.

This is a classic inversion of control, and allows for very flexible patterns. For example, using >>> (ie: raise if exception, otherwise continue) sidesteps the need for separate bang functions.

Just like the classic FP wisdom: if it doubt, pass it back to the caller to handle.

Examples

A simple way to declaw a function that normally raises. (Does not change the behaviour of functions that don't raise).

toothless_fetch=safe(&Enum.fetch!/2)[1,2,3]|>toothless_fetch.(1)#=> 2toothless=safe(&Enum.fetch!/2)[1,2,3]|>toothless.(999)#=> %Enum.OutOfBoundsError{message: "out of bounds error"}safe(&Enum.fetch!/2).([1,2,3],999)#=> %Enum.OutOfBoundsError{message: "out of bounds error"}
[1,2,3]~>Enum.sum()#=> 6Enum.OutOfBoundsError.exception("exception")~>Enum.sum#=> %Enum.OutOfBoundsError{message: "exception"}[1,2,3]|>hypothetical_returns_exception()~>Enum.map(fnx->x+1end)~>Enum.sum()#=> %Enum.OutOfBoundsError{message: "exception"}0..10|>Enum.take(3)~>Enum.map(fnx->x+1end)~>Enum.sum()#=> 6

Elixir and Erlang interoperate, but represent errors differently. normalize normalizes values into exceptions or plain values (no {:error, _} tuples). This can be seen as the opposite of the functions that convert back to tagged status. Some error types may not be detected; but you may pass a custom converter (see examples below).

normalize(42)#=> 42normalize(%Enum.OutOfBoundsError{message: "out of bounds error"})#=> %Enum.OutOfBoundsError{message: "out of bounds error"}normalize(:error)#=> %ErlangError{original: nil}normalize({:error,"boom"})#=> %ErlangError{original: "boom"}normalize({:error,{1,2,3}})#=> %ErlangError{original: {1, 2, 3}}normalize({:error,"boom with stacktrace",["trace"]})#=> %ErlangError{original: "boom with stacktrace"}normalize({:good,"tuple",["value"]})#=> {:good, "tuple", ["value"]}{:oh_no,{"something bad happened",%{bad: :thing}}}|>normalize(fn{:oh_no,{message,_}}->%File.Error{reason: message}# This case{:bang,message}->%File.CopyError{reason: message}otherwise->otherwiseend)#=> %File.Error{message: msg}{:oh_yes,{1,2,3}}|>normalize(fn{:oh_no,{message,_}}->%File.Error{reason: message}{:bang,message}->%File.CopyError{reason: message}otherwise->otherwise# This caseend)#=> {:oh_yes, {1, 2, 3}}
[1,2,3]|>hypothetical_returns_exception()~>Enum.map(fnx->x+1end)~>Enum.sum()#=> {:error, "exception"}0..10|>Enum.take(3)~>Enum.map(fnx->x+1end)~>Enum.sum()|>to_tagged_status()#=> {:ok, 6}0..10|>hypothetical_returns_exception()~>Enum.map(fnx->x+1end)~>Enum.sum()|>ok()#=> {:error, "exception"}maybe_sum=0..10|>hypothetical_returns_exception()~>Enum.map(fnx->x+1end)~>Enum.sum()~~~maybe_sum#=> {:error, "exception"}

Note that this does away with the need for separate foo and foo! functions, thanks to the inversion of control.

[1,2,3]>>>Enum.sum()#=> 6%ArgumentError{message: "raise me"}>>>Enum.sum()#=> ** (ArgumentError) raise meensure!([1,2,3])#=> [1, 2, 3]ensure!(%ArgumentError{message: "raise me"})#=> ** (ArgumentError) raise medefmoduleFoodouseExceptionaldef!foo(a),do: aendFoo.foo([1,2,3])#=> [1, 2, 3]Foo.foo(%ArgumentError{message: "raise me"})#=> %ArgumentError{message: "raise me"}Foo.foo!([1,2,3])#=> [1, 2, 3]Foo.foo!(%ArgumentError{message: "raise me"})#=> ** (ArgumentError) raise me
Exceptional.Control.branch1,value_do: fnv->v+1end.(),exception_do: fn%{message: msg}->msgend.()#=> 2ArgumentError.exception("error message"),|>Exceptional.Control.branch(value_do: fnv->vend.(),exception_do: fn%{message: msg}->msgend.())#=> "error message"if_exception1,do: fn%{message: msg}->msgend.(),else: fnv->v+1end.(),#=> 2
ArgumentError.exception("error message")|>if_exceptiondofn%{message: msg}->msgend.())elsefnv->vend.()end#=> "error message"

Related Packages

Releases

Packages

Used by

Contributors

Languages