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 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 KVStore a b where
docs.fundamentals.abilities.writingAbilities.KVStore.get :
a ->{KVStore a b} Optional b
docs.fundamentals.abilities.writingAbilities.KVStore.put :
a -> b ->{KVStore a b} ()
Ability declarations start with the ability
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
function 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.
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 Map
. Don't worry, we'll be breaking this down:
inMemory : '{g, KVStore a b} r ->{g} r
inMemory keyValueInteractions =
impl : 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 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 anya
orb
type which return any valuer
- The argument
keyValueInteractions
is 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.
- 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 theresume
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 theRequest
argument. - The
Request
type represents interactions withKVStore.get
andKVStore.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
isr
, and when applied tomyProgram
, the valuer
is 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 the
KVStore.get
function - It returns the expected value to the "rest of the program", represented by the variable
resume
- It returns the expected value to the "rest of the program", represented by the variable
- The handler "gets" values from the map when a program calls the
- 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
- It updates the internal state of the handler by calling
- The handler "puts" values into the map when a program calls the
- resuming the "rest of the program" with another call to the
impl
function ensures subsequent interactions with theKVStore
ability are passed the appropriate state
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 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 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, !
.
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 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, Map a b)
inMemoryWithMap : '{g, KVStore a b} r ->{g} (r, Map a b)
inMemoryWithMap keyValueInteractions =
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 Map.empty
Now, without changing anything about our myProgram
function, we have access to the Map
!
inMemoryWithMap myProgram⧨( "4"
, internal.Bin
2 3 4 internal.Tip (internal.Bin 1 5 6 internal.Tip internal.Tip)
)
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
- The second argument in the pattern match case represents the continuation:
- 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 thewith
keyword expects a function withRequest
as an argument