DocsLanguage
Effects and handlers.
Describe what a function does, then choose how to interpret its requests.
A pure function calculates a value and nothing else. An effectful function can also make a request: print something, read or change state, fail, or perform an operation you define yourself. Its type records which of these it may do, so you can see them without reading the body.
Read an effectful type.
announce : String ->{Console} ()
announce text = print text
main() = announce "hello"
announce takes a string, uses the console when it’s called, and returns Unit. A plain arrow such as Int -> Int is pure. Just binding an effectful function to a name, or passing it around, doesn’t call it.
Each arrow owns its own effects. In A -> B ->{Console} C, the first application is pure, and the console is used only when you supply the second argument. You’ll notice this when you partially apply a function.
Services and IO.
Console is one of the standard library’s services: small effects for what lies outside the program. Files are File.FileSystem, sockets Net.Network, arguments and environment variables Process.Environment, and the clock and randomness have effects of their own. A program’s main gets each service’s default handler, which does the real work.
Because a service is an ordinary effect, other handlers can answer it too. Console.runTest feeds input from bytes and captures the output, and File.runMemory keeps files in memory, so code that reads and writes can be tested without touching the system. Files, console, and sockets shows how.
The builtin IO effect is what remains underneath: it marks code that reaches the host directly, such as the services’ system handlers. Application code rarely needs to name it, and no handler can intercept it.
Effects and handlers.
An effect lets you separate a request from the way it’s answered. Here’s a small one:
effect Ask
answer : () -> Int
calculation : () ->{Ask} Int
calculation() = answer() + 1
main() =
result = handle calculation() on
answer () -> resume 41
return value -> value
print result
This prints 42. calculation asks for an answer without deciding where it comes from, and the handler supplies 41. resume sends that value back to the paused operation call, and the calculation carries on from there. The return clause handles the normal finish.
A handler interprets the operations it names, and any other effects pass through to its caller. Handlers are synchronous, and resumptions follow the language’s one-resume discipline.
Run a handler with use.
Library handlers such as State.run take a callback containing the work they interpret. With use, you can write that work as the rest of a block instead of adding another pair of braces:
import State
main() =
counted =
use State.run 10
State.put (State.get() + 1)
State.get()
print counted.value
print counted.state
11
11
The indented block assigned to counted is equivalent to State.run 10 { State.put (State.get() + 1); State.get() }. The runner receives the remaining statements as its callback and returns both their result and the final state. Its scope ends where that block ends.
Several use lines nest in source order, with the first runner outermost. That’s handy when a computation needs, say, both a failure handler and a state handler. use works with any function that takes a callback, not just effects. If the callback receives a value, bind it with use reader <- Reader.withBytes bytes and use reader in the rest of the block.
Abort operations.
Some requests end the computation rather than returning to it. The standard Fail effect is one of them:
import Fail exposing (Fail)
import Result exposing (Result(..))
checked : Int ->{Fail String} Int
checked number = if number < 0 then Fail.fail "negative input" else number
main() =
case Fail.attempt { checked (-1) } of
Ok number -> print number
Err message -> print message
This prints negative input. Fail.attempt turns a failure into a Result, and in doing so removes that failure effect from the outer computation. Its callback is a Unit function, which is why the checked call runs inside the handler.
An operation marked abort never returns to its caller, as Fail declares with abort fail : error -> value. One effect may mix abort and resumable operations:
effect Cursor item
peek : () -> Maybe item
advance : () -> ()
abort stop : String -> value
A handler resumes peek and advance, and answers stop without resuming. Once an effect has any abort operation, its handler can’t be inherited by a spawned task, so handle it inside the task.
Row inclusion and callback compatibility.
Higher-order functions carry the effects of the callbacks they call. The same List.map can do a pure transformation, or print something along the way:
report number =
print "checking #{number}"
number * 2
main() =
doubled = List.map report [1, 2, 3]
print doubled
checking 1
checking 2
checking 3
[2, 4, 6]
List.map has the shape (a ->{e} b) -> List a ->{e} List b. With report, the callback’s row e contains Console, while a pure callback leaves it pure. The effect happens as the list is traversed, not when the callback is handed over.
An open tail such as {Console, e} means Console plus whatever other effects remain. A row lists its effects first, separated by commas, and ends with at most one tail; an effect’s type arguments follow its name, so in {State s, e} the s belongs to State and e is the tail. A callback may perform fewer effects than its surrounding function allows. Effect arguments still matter: State Int and State String are different requests, so a row is more than a collection of effect names.
Exact definition annotations.
An annotation should describe what a definition really does. announce : String ->{Console} () fits a body that prints. A function that just returns its argument should be String -> String, even if every caller happens to be inside a program that prints.
Think of it as a property of the function, not a permission granted by whoever calls it. A call can fit into a larger row: using a pure helper inside an effectful computation doesn’t make the helper effectful. To write a pure arrow, leave the row out, since ->{} isn’t accepted syntax.
Handler clauses and abort routing.
Operations performed inside a handler clause belong to the clause’s surroundings, not to the computation being handled. In this example the handler answers a request by failing, and the outer failure handler receives that failure:
import Fail
effect Ask
answer : () -> Int
main() =
result = Fail.attempt {
handle answer() on
answer () -> Fail.fail "no answer available"
return number -> number
}
print result
Err "no answer available"
The answer clause aborts rather than resuming. A clause’s own requests aren’t caught again by the same handler, so if its interpretation needs another handler, put one inside the clause.
An abort can leave several nested regions on its way to its handler. Cleanup scopes release their resources during that exit, before the outer computation receives the failure.
Several applications in one handler.
A parameterized effect can occur at several types. The operation’s arguments and results must determine which application is being requested. When that’s ambiguous, add an annotation or a typed helper. The order of rows never selects an application.
Operation signatures.
An operation’s signature describes its arguments and result, and the checker adds the declaring effect to its callable type. An operation can also work for several value types:
effect Echo
echo : a -> a
main() =
result = handle (echo 7, echo "hello") on
echo item -> resume item
return pair -> pair
print result
(7, "hello")
The clause has to work for every instantiation of a, so it can return item but can’t assume it’s an integer or a string. Effects can take type parameters of their own as well, such as the stored value type of State a. An abort operation has no normally returning result and must not resume.
Stateful handlers.
For everyday local state, use State.run. To see how it works underneath, you can write a small counter handler yourself:
effect Counter
currentCount : () -> Int
setCount : Int -> ()
countTwice : () ->{Counter} Int
countTwice() =
setCount (currentCount() + 1)
setCount (currentCount() + 1)
currentCount()
main() =
result = handle countTwice() with current = 0 on
currentCount () -> resume current with current
setCount next -> resume () with next
return answer -> (answer, current)
print result
(2, 2)
with current = 0 initializes one cell of handler state. Each operation clause sees the current snapshot, and resume result with nextState supplies the operation’s result along with the state for later requests. The return clause sees the final state, so it can give back both the computation’s answer and the final counter.
That state is private to the handler’s clauses. It isn’t a variable that countTwice can see. Each new activation of the handler creates its own cell. When tasks share a handler, individual snapshots and commits are synchronized, but a separate read followed by a write is not an atomic update. For state that tasks change concurrently, use State.runAtomic and change it with one State.update or State.modify.
Resume discipline.
In a resumable operation clause, every path that completes normally must end with exactly one tail resume. The clause can work out the answer first and then hand it to the paused call. What it can’t do is save the continuation in a value, capture it in another function, or resume it twice.
A clause can instead abort through a failure effect, and then it doesn’t resume at all. An abort operation has no continuation to begin with, so its clause never resumes, even when the same effect also has resumable operations. A nested handler’s clauses have their own resumptions.
Closures and handler effects.
A callback’s type keeps the effects of calling it, and storing the callback in a record or union doesn’t hide them. Keep resource-backed callbacks within the lifetime of their resources, and use scoped APIs when a library introduces a fresh local permission.
