Error handling with abilities

We hope you've familiarized yourself with some of the common ways tohandle errors with Unison data types,this doc will discuss a different strategy for error management, using abilities. A more comprehensive introduction to abilitiescan 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 programwithoutthe additional complexity of introducing a data type to enclose a function's desired output. Errors are still captured in the type signatures of functions throughability 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 likeflatMapormapor special notation likedoorfor expressions,you can focus on the core behavior of your program, and delegate handling the error to enclosing handlers when absolutely necessary.

Abort

Abortis an ability which expresses the termination of a program without additional information about the failure.

Abort'ssolerequest constructorisabort.Here's an example of when you might useabortin a function:

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

Note that theAbortability requirementshows 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 belowmyFunctioncallserrorHandling.divByand therefore theAbortability requirement is present even thoughmyFunctionitself doesn't callabort.

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 prefixedtoorfromthe ability type name liketoEither.

TheAbortability requirement can be eliminated by translating it to anOptionalvalue with the handlertoOptional!.Function executions which encounter theabortcall get translated toNoneand function calls that are successful are wrapped inSome.

Vice-versa, you can translate anOptionalvalue into theAbortability with theOptional.toAbortfunction.

Some 4 |> toAbort

Throw

TheThrowability will stop a computation and return a value of aspecific typewhen an error condition occurs. For this reasonThrowis parameterized by the possible error type in function signatures which require it.Throwhas one request constructor,throw.Rewriting theerrorHandling.divByfunction to useThrowyields:

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 tothrowa value of typeTexthere, but in a larger application you might encode the particulars of your error in a custom data type.

Structurally, theThrowability shares much in common with theEitherdata 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 forThrowby translating it to aEitherwith thetoEitherhandler.

toEither '(divByThrow 1 0)
Left "Cannot divide by zero"

Exception

TheExceptionability is similar to theThrowability, except the error type is pinned to be a data type calledFailure.For more information about how to construct a value of typeFailurecheck out this document here.When a failure occurs, the Exception ability's request constructor,Exception.raise,surfaces relevantFailureinformation to the enclosing handler. Many of the functions in thebaselibrary express the possibility of errors in terms of this ability.

Here's how we might rewrite our function usingException:

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 theGeneric.failurefunction to help generate aFailurevalue. In a larger application with errors modeled as data types we might choose to construct our ownFailure.

The base library provides a handler to translate theExceptionability into value of typeEither Failure a.

catch '(divByException 1 0)
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.

🧠
TheExceptionability, along with theIOability, are the only two ability requirements that can remain unhandled in the return type of the function provided to theruncommand. The UCM runtime will stop the execution of the program which raises anExceptionwith theFailurein 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 thebaselibrary--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,donotation, etc) is absent from Unison, so it's often the simplest way to write code.

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