Can I define an ability which references other abilities?
You cannot, at present, define an ability that directly specifies another ability requirement in its request constructors. Let's say I wanted to write a Cache ability. I might want to express something like:
-- 🙅🏻♀️ you can't do this
ability Cache k v where
get : k -> {Cache k v, IO} v
put : k -> v -> {Cache k v, IO} ()
After all, I know it's likely the users of my service will want to do IO as a part of handling their get and put requests. However, we can't specify that in the ability definition. The preferred way to allow for a Cache ability to perform IO and exceptions would be to write a handler that interprets the Cache
ability into the IO
ability.
Can I require two of the same abilities in one function?
No, you cannot require two of the same ability in a function.
You might expect that the following function would fail.
cannotDo : '{Stream Nat, Stream Nat} Nat
cannotDo = do 4
Which Stream would we handle first? But the function below is also not allowed despite the fact that the two abilities are parameterized by different types.
cannotDoTypeConstructor : '{Store Nat, Store Text} Nat
cannotDoTypeConstructor = do 4
Our type system does not currently support this.
What does Request
mean?
The Request
type is the type that Unison uses to model the request constructors of an ability.
In the expression handle !myEffect with myEffectHandler
the term myEffectHandler
would have a type which looks something like Request (MyEffect) a -> a
- The exact semantics are described in the language guide section on handlers.
When defining your own handlers you might see the Request
type, but callers of handler functions shouldn't typically need to manage it directly.
Why can't an ability be at the "top-level" of a value?
For example, you can't do: myTerm = printLine "Hi"
.
Currently values at the "top level" of a program have to be pure, so it's more common to see delayed computations that contain abilities (expressed with the single quote syntax). This may change in a future version of Unison. Think of the ability constraint as a constraint on the function arrow as opposed to a constraint on a value.
How do I test something which requires the IO ability?
Run a single test which performs IO with the io.test command
. The io.test
command expects a delayed computation with IO
as an ability requirement. The rest of the Unison testing conventions remain unchanged.
You can also test functions which perform IO with Unison's transcripts by interleaving Unison code which performs IO with UCM fenced codeblocks that call the run
command.
A document about writing Unison transcripts can be found here.
How do you express failures when writing a custom ability?
For example, if you write a Http
ability or a Cache
ability, you may want to capture the fact that a request may fail.
It's tempting to write:
-- 🙅🏻♀️ you can't require an ability in the definition of an ability like this
unique ability Http where
handleRequest : Request -> {Exception} Response
Instead, here are a few strategies that you'll see employed:
- Rather than representing the failure in the definition of the ability itself, the handlers of the ability can account for failure, calling other abilities that represent a failure state, or by using data types that represent the failure.
- You might use a data type like
Optional
, orEither
in the type signatures of your ability's operations themselves. - Some abilities contain a specific request constructor that represents failure. For example, the Tokenizer ability on share This design decision is less common. A heuristic to use when deciding whether to build a "fail" operation into your ability is if the "fail" operation is one of the key behaviors of the effect you're trying to model, as opposed to an operational hazard.
Why does a handler need a case which doesn't refer to the ability operations?
Handlers have a case in their pattern match which takes the form {r} -> ...
.
This case needs to be present for the following reasons:
- It yields the last executed value of the block being handled to the rest of the program.
- It handles situations where an ability might be required by a function but is not ultimately used.
For example, this function won't always need to call Abort.abort
:
A handler for this function might look like:
Abort.toOptional! : '{g, Abort} a ->{g} Optional a
Abort.toOptional! f =
handle f()
with cases
{ r } -> Some r
{ Abort.abort -> resume } -> Optional.None
Abort.toOptional! do errorHandling.divBy 4 2⧨Some 2
Without the {r} -> ...
case, you cannot typecheck the handler.
Pattern match doesn't cover all possible cases:
2 | Abort.toOptional! f = handle !f with cases
3 | {abort -> resume} -> None
Patterns not matched:
* { _ }
What's the relationship between abilities and monads?
That deserves a longer discussion. The short answer is that abilities are as expressive as monads.
But for now check out this gist!
I see some code which leaves off the ability requirements in an ability declaration, what gives?
When defining an ability's operations, it's often a nice shorthand to leave off the implied ability requirement—that is, the {Abort}
in Abort.abort : {Abort} a
—after all, we know that an ability operation will be performing the ability in question and will require a handler. Unison will fill that in for us if we omit it, so we could have also defined the type signature of the abort operation as Abort.abort : x
.