Error handling with abilities

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:

errorHandling.divBy : Nat -> Nat ->{Abort} Nat
errorHandling.divBy a b = match b with
  0 -> Abort.abort
  n -> a Nat./ b

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.

myFunction : Nat -> Nat ->{Abort} Nat
myFunction a b =
  use Nat *
  errorHandling.divBy a b * 2
🎨
In Unison it's common to translate between errors as represented by a data type and errors as expressed through abilities, so you'll find that the abilities in the base library have handlers that do just that. Look for functions prefixed to or from the ability type name like toEither.

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.

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:

divByThrow : Nat -> Nat ->{Throw Text} Nat
divByThrow a b = match b with
  0 -> throw "Cannot divide by zero"
  n -> a Nat./ b

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 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…

If an exception is thrown the program will just crash.

🧠
The Exception ability, along with the IO ability, are the only two ability requirements that can remain unhandled in the return type of the function provided to the run command. The UCM runtime will stop the execution of the program which raises an Exception with the Failure in the console.

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?

🎨

As a rule of thumb we suggest you use the abilities-based approach for error handling because it allows you to express the intent of your program in a "direct" manner. Some of the syntax that makes managing errors-as-data-types easy (`for` expressions, do notation, etc) is absent from Unison, so it's often the simplest way to write code.

Of course, exercise your judgment and use whatever approach you like! 😊