Writing your own abilities

Say you couldn't find an ability inbasefor your particular use-case; Unison also allows you to write your own abilities to model a particular effect.

๐Ÿ‘‹This doc assumes some familiarity with the generalmental modelfor what an ability is, and a working understanding ofusingabilities and handlers. If not, check outparts oneandtwo,of this guide and head right back.

Writing ability declarations

Here's an example ability declaration for an effect that models a key value store. You might choose to make this ability as a way to implement caching for a function without having to add a specific key value store as an argument to functions that require access to it. We'll put it to use in a bit.

ability KVStore a b

Ability declarations start with theuniqueorstructuralkeyword, followed by the name of the ability and any lowercase type parameters that the ability might require. The type parameters here, lowercaseaandb,mean that this store can contain keys and values of a specific type.

TheKVStore.getandKVStore.putsignatures are the operations of the ability, which we callrequest constructors.In general, an ability can contain any number of request constructors. When naming your operations, ask yourself: independent of a particular implementation, what are the operations of the effect that I'm trying to model? For theKVStoreability, we wouldn't want to call the request operation "redisGet", and for this simple key value store ability a request constructor called "putWithCreateTime" arguably leaks implementation details into the ability definition that might best be relegated to a specific handler.

In the signature forKVStore.get,KVStore.get : a ->{KVStore a b} Optional b,theability requirementappears in the return type. On it's face, this makes sense, when you call theKVStore.getorKVStore.putoperations, you will need to handle the ability it performs!

Writing handlers

We've defined the interface for our ability, but to supply the desired behavior to the ability when it's called, we need to write a special Unison function called a handler.

๐Ÿง 
At a high level, a handler works by pattern matching on each of the functions of the ability. When a program evaluates a call toKVStore.getit looks to the enclosing handler of the function, then finds the branch of the pattern match that reflects the operation of the ability that has been called, and performs the actions specified there.

The following function will be the example we'd like to run with our handler. It writes two values into storage and gets one of them back, callingNat.toTexton it.

myProgram : '{KVStore Nat Nat} Text
myProgram _ =
  use KVStore put
  put 3 4
  put 5 6
  maybeFour = KVStore.get 3
  Optional.map Nat.toText maybeFour
    |> Optional.getOrElse "nothing here"

We'll deep dive into the syntax of handlers by writing a handler for ourKVStoreability next.

The parts of a handler

Let's look at a handler that enables interactions with a key value store backed by an in-memorydata.Map.Don't worry, we'll be breaking this down:

inMemory : '{g, KVStore a b} r ->{g} r
inMemory : '{g, KVStore a b} r ->{g} r
inMemory keyValueInteractions =
  impl : data.Map a b -> Request {KVStore a b} r -> r
  impl map = cases
    { pure } -> pure
    { KVStore.get key -> resume } ->
      handle resume (Map.get key map) with impl map
    { KVStore.put key value -> resume } ->
      updatedMap = Map.insert key value map
      handle !resume with impl updatedMap
  handle !keyValueInteractions with impl data.Map.empty

Each section below provides an overview of what the handler is doing, with a link to optionally read more in-depth information on a particular handler concept.

Line by line overview

inMemory : '{g, KVStore a b} r ->{g} r
inMemory keyValueInteractions =
  • In type signature we see that theKVStoreability is a delayed argument to the handler
  • It's polymorphic, meaning it can handleKVStoreinteractions of anyaorbtype which return any valuer
  • The argumentkeyValueInteractionsis the function we're trying to run. In our example, it stands in formyProgram

Read more about the type signatures of handlers


โ€ฆ
  handle !keyValueInteractions with impl Map.empty
  • This is the next line that Unison would execute once the handler is provided.
    • It initializes theKVStorewith an empty map as the storage backing.
      • Thisdata.Mapwill be updated in subsequent interactions with the ability
  • Thehandleโ€ฆ withkeywords tell Unison that this function is not just another functionusingthe ability, it is going to becapturing the requeststo the ability
  • Each time thehandleโ€ฆ withblock is repeatedit sets up the handler for the next request;that's why its important to handle theresumevariable

Read more about thehandleโ€ฆ withkeywords


โ€ฆ
    impl : Map a b -> abilities.Request {KVStore a b} r -> r
    impl map = cases
โ€ฆ
  • implis a helper function which holds state between requests
  • Thecasessyntaxused here is a way of pattern matching on theRequestargument.
  • TheRequesttype represents interactions withKVStore.getandKVStore.put

Read more about theRequesttype


โ€ฆ
  impl map = cases
    { pure } -> pure
โ€ฆ
  • The{pure} -> pureline reflects the fact that a given program may never use the ability and determines the value returned by the handler in that situation
  • In our handler signature, the type ofpureisr,and when applied tomyProgram,the valueris of typeText

Read more about returning pure values in handlers


โ€ฆ
  {KVStore.get key -> resume}       ->
    handle resume (Map.get key map) with impl map
  {KVStore.put key value -> resume} ->
    updatedMap = Map.insert key value map
    handle !resume with impl updatedMap
โ€ฆ
    • The handler "gets" values from the map when a program calls theKVStore.getfunction
      • It returns the expected value to the "rest of the program", represented by the variableresume
    • The handler "puts" values into the map when a program calls theKVStore.putfunction
      • It updates the internal state of the handler by callingimplwith an updated map
  • resuming the "rest of the program" with another call to theimplfunction ensures subsequent interactions with theKVStoreability are passed the appropriate state

Read more about resuming computations

Deep-dive: Writing handler concepts

The type signatures of handlers

One way to read the type signatureinMemory : '{g, KVStore a b} r ->{g} ris, "inMemory handles a function that uses a KVStore in the process of returning some value, r, and eliminates the ability, returning that r value."

You'll notice a lowercase variable in the type signature,{g}being passed through the ability requirements for the handler as well. This means that this handler allows other abilities to be called in the function being handled and will pass along the ability requirement for its respective enclosing handler function to manage.

TheinMemoryhandler contains a helper function with the signatureimpl : Map a b -> Request (KVStore a b) r -> r.The helper function's first argument is thedata.Mapthat we'll be updating to contain state internal to the handler. The second argument starting withRequest (KVStore a b) ris Unison's type which represents requests to perform the ability's operations (here those requests are things likeput 3 4orget 3).

Resuming computations

If you look at the cases in our pattern match,{KVStore.get k -> resume},you'll notice there's a variable representing the argument toKVStore.getand also a variable calledresume.This is because a handler allows you to access a snapshot of the of thearguments to the requestand the program stateas it is running.You might hear the variable for "resuming computations" called acontinuation.resumeis a function whose argument is always the return type of the request operation in question, for example,KVStore.get : a ->{KVStore a b} Optional breturns anOptional,so that's the value provided toresumeafter looking up the key in thedata.Map.

The fact that thecontinuationis reflected as a variable in the handler opens up possibilities for, say, terminating the computation, rerunning the computation, or even storing it!

The handle ... with keywords

It's important to initialize orresumeacontinuationwith thehandle... withkeywords. By wrapping the call toresumewith a recursive call to the handler implementation, we're telling the program, "this is how you should handle the next request." In this way, we're ensuring that the next call to the ability is being managed appropriately. The handler given here can contain state between requests as an argument, like themapin ourKVStoreexample.

Handling theRequesttype

Thehandlekeyword is followed by a function which requires the ability in question and thewithkeyword expects a function which includes theRequesttype as an argument. When a function includes theRequesttype in its signature, it tells Unison that the function will be pattern matching on the operations of the ability.

It's common for top-level handlers to accept a delayed computation, but remember, theRequesttype represents actual requests to your ability, so don't forget the bang operator,!.

๐ŸŽจ

Handlers often follow a pattern where the top level of a handler will expose a signature with no explicit reference to theRequesttype, likeinMemory : '{g, KVStore a b} r ->{g} r,which in turn delegates to animplhandler containing theRequestpattern matching implementation. This means callers of the handler can write expressions like:

inMemory myProgram
โงจ
"4"

instead of:

(handle !myProgram with inMemory.impl Map.empty)

The return type of a handler

Handlers contain a "done" or "pass-through" or "pure" case for when functions which require an ability don't end up executing the ability operations, or when function is done calling the ability. This is represented by the pattern match case which does not reference the request constructors of the ability:{pure} -> pure.In our example handler,inMemory : '{g, KVStore a b} r ->{g} rthe type of this "pure" case is represented by ther.

Handlers can even change this value as part of the behavior they supply to the program. Say we wanted to return both the value,randthe final state of thedata.Mapafter running the effect. We can do that by changing thepurecase and associated type signature:

inMemoryWithMap : '{g, KVStore a b} r ->{g} (r, data.Map a b)
inMemoryWithMap : '{g, KVStore a b} r ->{g} (r, data.Map a b)
inMemoryWithMap keyValueInteractions =
  use data Map
  impl : Map a b -> Request {KVStore a b} r -> (r, Map a b)
  impl map = cases
    { pure } -> (pure, map)
    { KVStore.get key -> resume } ->
      handle resume (Map.get key map) with impl map
    { KVStore.put key value -> resume } ->
      updatedMap = Map.insert key value map
      handle !resume with impl updatedMap
  handle !keyValueInteractions with impl data.Map.empty

Now, without changing anything about ourmyProgramfunction, we have access to thedata.Map!

Summary

  • Writing a handler involves pattern matching on therequest constructorsof the ability
    • You can explicitly control what value the program receives next throughcontinuations
      • The second argument in the pattern match case represents the continuation:{op -> resume}
      • The type of the value provided to the continuation must be the return type of the ability operation being called
  • Handlers contain a "pure" case,{pure} -> โ€ฆ,which represents when the function is done calling the ability or when the ability is not called
  • Thehandlekeyword expects a function which requires the ability and thewithkeyword expects a function withRequestas an argument