Errors are not exceptional
TL;DR. Only use exceptions for truly exceptional cases. Try OK.
Hello, my friend
Let us start with some simple functionality. Given the ID for a user, in this case an integer, return a greeting for that user. The first iteration of a greet function could look something like this:
@spec greet(integer) :: String.t()
def greet(user_id) do
{:ok, user} = fetch_user(user_id)
{:ok, first_name} = first_name(user)
{:ok, second_name} = second_name(user)
"Hello #{first_name} #{second_name}"
end
This version appears to do everything we need; when we are given a user ID we are returned a string.
The good thing about this function is what it does is very clear. The downside is that it will crash if a user can't be found or if either part of their name is unset.
Full names
Assuming the full name of a user is used in many places in this project, the fact a name is stored as two parts, first and second, is just a detail. It would be useful to have a single function that returns a user's full name.
In some cases if the full name is not available the programs need to do some follow up actions,
so this function should not crash.
Instead we will lean on the erlang convention and return the result wrapped in an
:ok
tuple tuple or an
:error
tuple tuple with reason.
@spec fetch_full_name(%User{}) ::
{:ok, String.t()} | {:error, term}
Composing errors
The OK library includes the for macro, usef to compose functions that return errors. This can be used to compose the functions to fetch parts of a name that might error into one for fetching the full name.
def fetch_full_name(user) do
OK.for do
first_name <- fetch_first_name(user)
second_name <- fetch_second_name(user)
after
first_name <> " " <> second_name
end
end
The <-
in this example is the bind operator.
If the function on the right returns a value wrapped in an :ok
tuple,
then the value in that tuple is bound to the variable.
When an :error
tuple is on the right side of the bind,
then the "for" block stops processing and returns the error tuple.
If every variable in the "for" block is bound successfully then the "after" block is executed.
The value from the "after" block is returned wrapped in an :ok
tuple.
The "for" macro will only return values in a result tuple. These functions can then be composed further in another "for" macro, or a "try" macro that we will see next.
Fixing errors
For our greeting example we need to return a string, or there will be a crash.This means handling the error cases. The OK library includes a try macro for handling errors.
@spec greet(integer) :: String.t()
def greet(user_id) do
OK.try do
user <- fetch_user(id)
name <- full_name(user)
after
"Hello " ++ name
rescue
_ ->
"And you are?"
end
end
In this example the bind operator does the same thing as before.
However the return from the "after" block is not wrapped in an :ok
tuple.
There is also an additional "rescue" block. This accepts a series of cases to handle the error cases from the main block. In this example we just treat all errors the same.
Because the "after" and "rescue" blocks always return a string the return value of the greet function is also a string.
The unknown unknown
There is one more error case to be handled in the code above.
"Hello " ++ name # this is not how strings are combined
"Hello " <> name # this is how strings are combined
A program should always deal with bad input. However it is always possible that some cases have not been thought about. And programmer error, like this example, is always possible.
This is where the "Let it crash" philosophy comes in. When something truly exceptional occurs, that the program cannot recover from, then the process should crash. However if other processes are not reliant on the crashed process they should continue.
Elegant error handling 2.0
OK is build on the concept of result monads, to make working with errors better. It is simple to get started with because it builds on the existing conventions of erlang, and Elixr.
Version 2.0 was recently released. It includes:
- improved typespecs that make working with dialyzer easier.
- simpler way to get started with pipes that handle errors.
use OK.Pipe
The library cannot, and does not try, to solve all the problems in a project. This is why it does not capture exceptions, it only works on explicit errors returned as tuples.