Using abilities part 2

In Part 1 of the guide for using Unison abilities, we covered how to call ability operations and represent abilities in type signatures. This document will describe how to apply existing handlers from the base library to programs which use Unison abilities. 👋 As a heads up, this doc doesn't go into conventions for writing your own handlers - it's just about using handlers.

What is a handler?

A handler is a Unison function which supplies the implementation or behavior for a given effect.

Handlers eliminate the ability requirement in a few common ways:

  • By interpreting the ability into a Unison data type or value
  • By interpreting the ability into other abilities

Handling an ability

First let's try to call effectfulPredicate.stopIfTrue in a function that neither provides a handler nor passes the ability requirement up the call chain.

The following code tries to call effectfulPredicate.stopIfTrue in a pure function (remember, the {} means the function does not perform abilities).

nonEmptyName : Text -> {} Text
nonEmptyName name =
  stopIfTrue (text -> text === "") name

The error we get back is:

The expression in red needs the {Abort} ability, but this location does not have access to any abilities.

  4 |   stopIfTrue (text -> text === "") name
🧠
This error is one to familiarize yourself with. It means that you've called a function which performs an ability without specifying the implementation for how the ability should be handled in a place where the ability requirement can't be passed along.

In the above example, how could the program actually interpret the underlying call to Abort.abort? 🤔 We might want to abort by crashing the program, or we could abort by translating the ability into a data type, or we could abort by translating the operation into another ability that might be handled elsewhere. But Abort.abort has not been interpreted at this location in any of these ways, nor is the ability requirement represented in the signature of the function that calls it.

Fortunately, the Unison standard library provides handlers for the abilities we've seen, so let's translate Abort into the Optional data type and then provide a sensible default.

usingAbilitiesPt1.nonEmptyName : Text -> Text
usingAbilitiesPt1.nonEmptyName name =
  optionalName : Optional Text
  optionalName = 
    toOptional! do
      effectfulPredicate.stopIfTrue (text -> text === "") name
  Optional.getOrElse "Unknown Name" optionalName

There are a few things to call attention to in the code.

First, you'll notice that the Abort ability requirement is nowhere to be found in the signature for usingAbilitiesPt1.nonEmptyName. It's been eliminated by the toOptional! handler function.

Take a closer look at the signature of toOptional!:

toOptional! : '{g, Abort} a ->{g} Optional a

This function expects a delayed computation so it's not as simple as slotting effectfulPredicate.stopIfTrue in as an argument. Instead we need to delay result of calling effectfulPredicate.stopIfTrue. We do this by surrounding the entire expression we want delayed with parentheses and adding a single quote ' to the front.

🧠
It's easy to make the mistake of putting the single quote next to the function to delay without surrounding it in parentheses, thinking "the entire expression to the right will be evaluated and wrapped in the thunk." 'myEffect arg only delays the function myEffect whereas '(myEffect arg) delays the result of evaluating myEffect arg. The latter is typically what you want.

With the call to Abort.abort interpreted into the Optional data type, we can then use standard functional combinators on Optional to provide a sensible default Text value for our function.

🎨

By convention, handlers that do not return a delayed computation, like toOptional! or toDefault! end with an exclamation mark ! to distinguish them from their counterparts which return delayed computations.

Compare the signature of toDefault!

toDefault! : '{g} a -> '{g, Abort} a ->{g} a

with the corresponding signature returning the thunk in toDefault

toDefault : '{g} a -> '{g, Abort} a -> '{g} a

We might have also chosen to interpret Abort ability with Abort.toBug or toDefault!. Look at the similarities in the signatures of all three of these handlers:

toOptional! : '{g, Abort} a ->{g} Optional a
toDefault! : '{g} a -> '{g, Abort} a ->{g} a
Abort.toBug : '{g, Abort} a ->{g} a

All of them take in a delayed computation which performs the Abort effect and return a value with the ability requirement removed.

Handling multiple abilities

We've seen how simple it is for functions to perform multiple effects at once with our store.stopIfTrue code. When a function performs multiple effects it's common to nest handlers inside one another, with each handler peeling off one ability requirement. Handlers are just functions after all!

As a refresher the abilities we are trying to eliminate are in the signature store.stopIfTrue : (a -> Boolean) -> a ->{Abort, Store a} a

store.nonEmptyName : Text -> Text
store.nonEmptyName name =
  storeIsHandled : '{Abort} Text
  storeIsHandled =
    do
      withInitialValue "Store Default Value" do
        store.stopIfTrue (text -> text === "") name
  abortIsHandled : Optional Text
  abortIsHandled = toOptional! storeIsHandled
  Optional.getOrElse "Optional Default Value" abortIsHandled

In the example above we're eliminating the Store ability first with the withInitialValue handler. It's a function which ensures the Store has been seeded with some value so if subsequent functions call Store.get they're guaranteed to return something.

Then we eliminate the Abort ability by transforming it into a Optional value.

One caveat: the order in which the handlers are applied can change the value returned! Handlers respect the rules of regular function application, so you might see them grouped with parentheses or function application operators when you're defining a Unison function that you intend to run in a watch expression or with the UCM run command.

Abilities and top level definitions

🎨 It's common to express the core of your Unison program in terms of abilities and translate your abilities into Unison standard data types or IO because abilities cannot be left unhandled "at the top level" of a file

🙅🏻‍♀️ A term like this will not typecheck:

tryEmit : {Stream Text}()
tryEmit = Stream.emit "Hi"

The quick solution for this is to delay the computation, with a thunk, so that the {Stream Text} abilities requirement is once again found on the right side of the arrow. With a delayed computation, the signature would become tryEmit : '{Stream Text}() or tryEmit : () -> {Stream Text}() without the syntactic sugar of the single quote.

Any one of these will work

tryEmit1 : '{Stream Text}()
tryEmit1 _ = Stream.emit "Hello World"

tryEmit2 : '{Stream Text}()
tryEmit2 = 'let
  Stream.emit "Hello World"

tryEmit3 : '{Stream Text}()
tryEmit3 = '(Stream.emit "Hello World")
Abilities are a property of the function that they're exercised in, not a property of a value. A good mental model for abilities is that the ability requirement can be thought of as decorating or being attached to the the function arrow.

Note that the trick of adding a thunk to a value that performs an ability will enable your code to typecheck, but the application of a handler is the only way to run an effectful function in a watch expression.

The IO ability

Unison has a special built-in handler for functions which perform I/O. The IO ability is the only Unison ability whose handler is provided by the runtime, not by specific handler functions.

If we'd like to integrate our existing function into one which performs I/O via the console, we can do that like this:

nameGreet : '{IO, Exception} ()
nameGreet _ =
  use Text ++
  printLine "Enter your name:"
  name = readLine()
  printLine ("Hello " ++ name)

This function uses the readLine function in the base library to get the user's name and the printLine function to render it. Both are effectful and require the IO and Exception abilities. readLine returns a delayed computation, so in order get the Text value from the user we evaluate the thunk with the exclamation mark. The entire function is wrapped in a thunk because nameGreet _ is syntactic sugar for a delayed computation, so the final signature of the expression is:

This signature is important for the IO handler. That's because at the top level of a Unison program, a function which performs IO can only be called via the UCM with the run command.

It just so happens the run command provides the implementation details for handling both the IO and the Exception abilities, so we don't need a specific Exception handler in this case.

The run command expects a delayed computation with a signature of '{Exception} () or '{IO} () or both. Currently returning a value other than unit is not supported.
scratch/main> run nameGreet
🧠

To review:

Handlers are just functions that provide behavior to abilities - they often interpret the ability into data types or into other abilities.

Don't forget your parens when a handler accepts a delayed computation, '{MyAbility} a. 😎

Abilities are properties of functions (not values), so they cannot be unhandled at the top level of a term.

The IO ability's handler is provided by the Unison runtime. 🎉

Where to next?

🌟 Error handling with abilities

📚 Writing ability handlers