We hope you've familiarized yourself with some of the common ways to handle errors with Unison data types, this doc will discuss a different strategy for error management, using abilities. A more comprehensive introduction to abilities can be found here, but this doc does not rely on a detailed understanding of algebraic effects or writing ability handlers. In this doc we'll go through some common patterns and examples for expressing and handling errors with abilities in Unison.
Why use abilities for error handling?
Abilities allow us to express the possibility that an error might occur in a program without the additional complexity of introducing a data type to enclose a function's desired output. Errors are still captured in the type signatures of functions through ability requirements, so you're still able to reason about the behavior of your program at the type level, but rather than managing the effect's presence in the remainder of the program through calls to functions like flatMap
or map
or special notation like do
or for expressions
, you can focus on the core behavior of your program, and delegate handling the error to enclosing handlers when absolutely necessary.
Abort
Abort
is an ability which expresses the termination of a program without additional information about the failure.
Abort
's sole request constructor is Abort.abort
. Here's an example of when you might use Abort.abort
in a function:
Note that the Abort
ability requirement shows up in the return type of the function. This means that the caller of this function will either need to handle the ability or express the ability requirement in its own signature. In the example below myFunction
calls errorHandling.divBy
and therefore the Abort
ability requirement is present even though myFunction
itself doesn't call Abort.abort
.
The Abort
ability requirement can be eliminated by translating it to an Optional
value with the handler toOptional!
. Function executions which encounter the Abort.abort
call get translated to Optional.None
and function calls that are successful are wrapped in Some
.
toOptional! do errorHandling.divBy 4 2⧨Some 2
Vice-versa, you can translate an Optional
value into the Abort
ability with the Optional.toAbort
function.
Some 4 |> toAbort
Throw
The Throw
ability will stop a computation and return a value of a specific type when an error condition occurs. For this reason Throw
is parameterized by the possible error type in function signatures which require it. Throw
has one request constructor, throw
. Rewriting the errorHandling.divBy
function to use Throw
yields:
We've chosen to throw
a value of type Text
here, but in a larger application you might encode the particulars of your error in a custom data type.
Structurally, the Throw
ability shares much in common with the Either
data type. Both capture information about the type of the error and both return a value of that type in the event of a failure. They offer the capability of providing enriched error reporting. You can eliminate the ability requirement for Throw
by translating it to a Either
with the toEither
handler.
toEither do divByThrow 1 0⧨Either.Left "Cannot divide by zero"
Exception
The Exception
ability is similar to the Throw
ability, except the error type is pinned to be a data type called Failure
. For more information about how to construct a value of type Failure
check out this document here. When a failure occurs, the Exception ability's request constructor, Exception.raise
, surfaces relevant Failure
information to the enclosing handler. Many of the functions in the base
library express the possibility of errors in terms of this ability.
Here's how we might rewrite our function using Exception
:
divByException : Nat -> Nat ->{Exception} Nat
divByException : Nat -> Nat ->{Exception} Nat
divByException a b = match b with
0 -> Exception.raise (Generic.failure "Cannot divide by zero" b)
n -> a Nat./ b
In the above code, we're using the Generic.failure
function to help generate a Failure
value. In a larger application with errors modeled as data types we might choose to construct our own Failure
.
The base library provides a handler to translate the Exception
ability into value of type Either Failure a
.
catch do divByException 1 0⧨Either.Left
(Failure (typeLink Generic) "Cannot divide by zero" (Any 0))
❗️ But if you're feeling bold you can also run…
unsafeRun! do divByException 4 2⧨2
If an exception is thrown the program will just crash.
Which strategy for error handling should I prefer?
Given the correspondence between Unison abilities and Unison data types and the ease with which you can translate between the two with functions provided by the base
library—which should you reach for idiomatically?