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
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.
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.
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 : 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")
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.
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.
scratch/main> run nameGreet