Using abilities part 2

InPart 1of 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 forwriting 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 theability requirementin a few common ways:

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

Handling an ability

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

The following code tries to calleffectfulPredicate.stopIfTruein 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 toabort?🤔 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. Butaborthas not been interpreted at this location in any of these ways, nor is theability requirementrepresented 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 translateAbortinto theOptionaldata type and then provide a sensible default.

usingAbilitiesPt1.nonEmptyName : Text -> Text
usingAbilitiesPt1.nonEmptyName name =
  optionalName : Optional Text
  optionalName =
    toOptional!
      '(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 theAbortability requirement is nowhere to be found in the signature forusingAbilitiesPt1.nonEmptyName.It's been eliminated by thetoOptional!handler function.

Take a closer look at the signature oftoOptional!:

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

This function expects adelayed computationso it's not as simple as slottingeffectfulPredicate.stopIfTruein as an argument. Instead we need to delay result of callingeffectfulPredicate.stopIfTrue.We do this by surrounding theentire expressionwe 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 argonly delays thefunctionmyEffectwhereas'(myEffect arg)delays theresult of evaluatingmyEffect arg.The latter is typically what you want.

With the call toabortinterpreted into theOptionaldata type, we can then use standard functional combinators onOptionalto provide a sensible defaultTextvalue for our function.

🎨

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

Compare the signature oftoDefault!

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

with the corresponding signature returning the thunk intoDefault

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

We might have also chosen to interpretAbortability withAbort.toBugortoDefault!.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 theAborteffect 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 ourstore.stopIfTruecode. 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 signaturestore.stopIfTrue : (a -> Boolean) -> a ->{Abort, Store a} a

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

In the example above we're eliminating theStoreability first with thewithInitialValuehandler. It's a function which ensures theStorehas been seeded with some value so if subsequent functions callStore.getthey're guaranteed to return something.

Then we eliminate theAbortability by transforming it into aOptionalvalue.

One caveat: the order in which the handlers are applied can change the value returned! Handlers respect therules of regular function application,so you might see them grouped with parentheses orfunction application operatorswhen you're defining a Unison function that you intend to run in awatch expressionor with the UCMruncommand.

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 orIObecause 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 todelay 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 becometryEmit : '{Stream Text}()ortryEmit : () -> {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 thefunctionthat 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 thefunction arrow.

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

The IO ability

Unison has a special built-in handler for functions which perform I/O. TheIOability 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 = !console.getLine
  printLine ("Hello " ++ name)

This function uses theconsole.getLinefunction in the base library to get the user's name and theprintLinefunction to render it. Both are effectful and require theIOandExceptionabilities.console.getLinereturns a delayed computation, so in order get theTextvalue from the user we evaluate the thunk with the exclamation mark. The entire function is wrapped in a thunk becausenameGreet _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 performsIOcan only be called via the UCM with theruncommand.

It just so happens theruncommand provides the implementation details for handling both theIOand theExceptionabilities, so we don't need a specificExceptionhandler in this case.

Theruncommand expects a delayed computation with a signature of'{Exception} ()or'{IO} ()or both. Currently returning a value other than unit is not supported.
.> 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.

TheIOability's handler is provided by the Unison runtime. 🎉

Where to next?

🌟Error handling with abilities

📚Writing ability handlers