Writing your own abilities

Say you couldn't find an ability in base for 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 general mental model for what an ability is, and a working understanding of using abilities and handlers. If not, check out parts one and two, 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 declarations start with the unique or structural keyword, followed by the name of the ability and any lowercase type parameters that the ability might require. The type parameters here, lowercase a and b, mean that this store can contain keys and values of a specific type.

The KVStore.get and KVStore.put signatures are the operations of the ability, which we call request 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 the KVStore ability, 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 for KVStore.get, KVStore.get : a ->{KVStore a b} Optional b, the ability requirement appears in the return type. On it's face, this makes sense, when you call the KVStore.get or KVStore.put operations, 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 to KVStore.get it 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, calling Nat.toText on 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 our KVStore ability next.

The parts of a handler

Let's look at a handler that enables interactions with a key value store backed by an in-memory data.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 the KVStore ability is a delayed argument to the handler
  • It's polymorphic, meaning it can handle KVStore interactions of any a or b type which return any value r
  • The argument keyValueInteractions is the function we're trying to run. In our example, it stands in for myProgram

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 the KVStore with an empty map as the storage backing.
      • This data.Map will be updated in subsequent interactions with the ability
  • The handle… with keywords tell Unison that this function is not just another function using the ability, it is going to be capturing the requests to the ability
  • Each time the handle… with block is repeated it sets up the handler for the next request; that's why its important to handle the resume variable

Read more about the handle… with keywords


impl : Map a b -> abilities.Request {KVStore a b} r -> r
    impl map = cases
  • impl is a helper function which holds state between requests
  • The cases syntax used here is a way of pattern matching on the Request argument.
  • The Request type represents interactions with KVStore.get and KVStore.put

Read more about the Request type


impl map = cases
    { pure } -> pure
  • The {pure} -> pure line 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 of pure is r, and when applied to myProgram, the value r is of type Text

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 the KVStore.get function
      • It returns the expected value to the "rest of the program", represented by the variable resume
    • The handler "puts" values into the map when a program calls the KVStore.put function
      • It updates the internal state of the handler by calling impl with an updated map
  • resuming the "rest of the program" with another call to the impl function ensures subsequent interactions with the KVStore ability 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 signature inMemory : '{g, KVStore a b} r ->{g} r is, "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.

The inMemory handler contains a helper function with the signature impl : Map a b -> Request (KVStore a b) r -> r. The helper function's first argument is the data.Map that we'll be updating to contain state internal to the handler. The second argument starting with Request (KVStore a b) r is Unison's type which represents requests to perform the ability's operations (here those requests are things like put 3 4 or get 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 to KVStore.get and also a variable called resume. This is because a handler allows you to access a snapshot of the of the arguments to the request and the program state as it is running. You might hear the variable for "resuming computations" called a continuation. resume is a function whose argument is always the return type of the request operation in question, for example, KVStore.get : a ->{KVStore a b} Optional b returns an Optional, so that's the value provided to resume after looking up the key in the data.Map.

The fact that the continuation is 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 or resume a continuation with the handle... with keywords. By wrapping the call to resume with 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 the map in our KVStore example.

Handling the Request type

The handle keyword is followed by a function which requires the ability in question and the with keyword expects a function which includes the Request type as an argument. When a function includes the Request type 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, the Request type 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 the Request type, like inMemory : '{g, KVStore a b} r ->{g} r, which in turn delegates to an impl handler containing the Request pattern matching implementation. This means callers of the handler can write expressions like:

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} r the type of this "pure" case is represented by the r.

Handlers can even change this value as part of the behavior they supply to the program. Say we wanted to return both the value, r and the final state of the data.Map after running the effect. We can do that by changing the pure case 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 our myProgram function, we have access to the data.Map!

Summary

  • Writing a handler involves pattern matching on the request constructors of the ability
    • You can explicitly control what value the program receives next through continuations
      • 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
  • The handle keyword expects a function which requires the ability and the with keyword expects a function with Request as an argument