Documentation structure inspired by Rust Book, Elm Guide, and others: Guide (10 chapters): - Introduction and setup - Basic types (Int, String, Bool, List, Option, Result) - Functions (closures, higher-order, composition) - Data types (ADTs, pattern matching, records) - Effects (the core innovation) - Handlers (patterns and techniques) - Modules (imports, exports, organization) - Error handling (Fail, Option, Result) - Standard library reference - Advanced topics (traits, generics, optimization) Reference: - Complete syntax reference Tutorials: - Calculator (parsing, evaluation, REPL) - Dependency injection (testing with effects) - Project ideas (16 projects by difficulty) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
7.1 KiB
7.1 KiB
Chapter 6: Handlers
Handlers are where effects become powerful. They define how effect operations behave, and they can do surprising things.
Basic Handler Structure
handler handlerName: EffectName {
fn operation1(param1, param2) = {
// Implementation
resume(result)
}
fn operation2(param) = {
// Implementation
resume(result)
}
}
Key points:
- Handlers implement all operations of an effect
resume(value)continues the computation withvalue- Handlers can use other effects in their implementations
Understanding Resume
resume is the key to handler power. It continues the computation from where the effect was called.
effect Ask {
fn ask(prompt: String): Int
}
fn computation(): Int with {Ask} = {
let x = Ask.ask("first") // Pauses here
let y = Ask.ask("second") // Then here
x + y
}
handler doubler: Ask {
fn ask(prompt) = resume(2) // Always returns 2
}
run computation() with { Ask = doubler }
// Returns: 4 (2 + 2)
The flow:
computationcallsAsk.ask("first")- Handler receives control, calls
resume(2) computationcontinues withx = 2computationcallsAsk.ask("second")- Handler receives control, calls
resume(2) computationcontinues withy = 2- Returns
4
Handlers That Don't Resume
Not all handlers must resume. Some can abort:
effect Validate {
fn check(condition: Bool, message: String): Unit
}
fn validateAge(age: Int): String with {Validate} = {
Validate.check(age >= 0, "Age cannot be negative")
Validate.check(age < 150, "Age seems unrealistic")
"Valid age: " + toString(age)
}
handler strictValidator: Validate {
fn check(cond, msg) =
if cond then resume(())
else "Validation failed: " + msg // Returns early, doesn't resume
}
run validateAge(-5) with { Validate = strictValidator }
// Returns: "Validation failed: Age cannot be negative"
Handlers Using Other Effects
Handlers can use effects in their implementation:
effect Logger {
fn log(msg: String): Unit
}
handler consoleLogger: Logger {
fn log(msg) = {
Console.print("[LOG] " + msg) // Uses Console effect
resume(())
}
}
fn main(): Unit with {Console} = {
let result = run {
Logger.log("Hello")
Logger.log("World")
42
} with { Logger = consoleLogger }
Console.print("Result: " + toString(result))
}
Stateful Handlers
Handlers can maintain state using the State effect:
effect Counter {
fn increment(): Unit
fn get(): Int
}
handler counterHandler: Counter {
fn increment() = {
State.put(State.get() + 1)
resume(())
}
fn get() = resume(State.get())
}
fn counting(): Int with {Counter} = {
Counter.increment()
Counter.increment()
Counter.increment()
Counter.get()
}
fn main(): Unit with {Console} = {
let result = run {
run counting() with { Counter = counterHandler }
} with { State = 0 }
Console.print("Count: " + toString(result)) // Count: 3
}
Handler Patterns
The Reader Pattern
Provide read-only context:
effect Config {
fn get(key: String): String
}
handler envConfig: Config {
fn get(key) = resume(Process.env(key))
}
handler mapConfig(settings: Map<String, String>): Config {
fn get(key) = resume(Map.getOrDefault(settings, key, ""))
}
The Writer Pattern
Accumulate output:
effect Log {
fn write(msg: String): Unit
}
handler collectLogs: Log {
fn write(msg) = {
State.put(State.get() + msg + "\n")
resume(())
}
}
fn program(): Int with {Log} = {
Log.write("Starting")
let result = 42
Log.write("Done")
result
}
// Get both result and logs
let (result, logs) = run {
run program() with { Log = collectLogs }
let logs = State.get()
(result, logs)
} with { State = "" }
The Exception Pattern
Early termination with cleanup:
effect Fail {
fn fail(msg: String): Unit
}
handler catchFail: Fail {
fn fail(msg) = Err(msg) // Don't resume, return error
}
fn riskyOperation(): Int with {Fail} = {
if Random.bool() then Fail.fail("Bad luck!")
else 42
}
let result: Result<Int, String> = run riskyOperation() with { Fail = catchFail }
The Choice Pattern
Non-determinism:
effect Choice {
fn choose(options: List<T>): T
}
fn picker(): Int with {Choice} = {
let x = Choice.choose([1, 2, 3])
let y = Choice.choose([10, 20])
x + y
}
// Handler that returns first option
handler firstChoice: Choice {
fn choose(opts) = resume(List.head(opts))
}
// Handler that returns all combinations
handler allChoices: Choice {
fn choose(opts) =
List.flatMap(opts, fn(opt: T): List<T> => resume(opt))
}
Combining Multiple Handlers
Multiple effects, multiple handlers:
effect Logger { fn log(msg: String): Unit }
effect Counter { fn count(): Int }
fn program(): Int with {Logger, Counter} = {
Logger.log("Starting")
let n = Counter.count()
Logger.log("Got " + toString(n))
n * 2
}
handler myLogger: Logger {
fn log(msg) = { Console.print(msg); resume(()) }
}
handler myCounter: Counter {
fn count() = resume(42)
}
let result = run program() with {
Logger = myLogger,
Counter = myCounter
}
Handler Scope
Handlers apply to their run block:
handler loudLogger: Logger {
fn log(msg) = { Console.print("!!! " + msg + " !!!"); resume(()) }
}
handler quietLogger: Logger {
fn log(msg) = resume(()) // Silent
}
fn program(): Unit with {Logger, Console} = {
Logger.log("Outer")
let inner = run {
Logger.log("Inner")
42
} with { Logger = quietLogger }
Logger.log("Back to outer")
Console.print("Inner result: " + toString(inner))
}
run program() with { Logger = loudLogger }
// Output:
// !!! Outer !!!
// !!! Back to outer !!!
// Inner result: 42
The inner run uses quietLogger, so "Inner" is silent.
Real-World Example: Database Testing
effect Database {
fn query(sql: String): List<Row>
fn execute(sql: String): Int
}
// Production handler using real database
handler postgresDb(conn: Connection): Database {
fn query(sql) = resume(Postgres.query(conn, sql))
fn execute(sql) = resume(Postgres.execute(conn, sql))
}
// Test handler using in-memory data
handler mockDb(data: List<Row>): Database {
fn query(sql) = resume(data)
fn execute(sql) = resume(1)
}
fn getUserCount(): Int with {Database} = {
let rows = Database.query("SELECT COUNT(*) FROM users")
extractCount(rows)
}
// Production
run getUserCount() with { Database = postgresDb(realConnection) }
// Testing - no database needed!
run getUserCount() with { Database = mockDb([Row { count: 42 }]) }
Summary
| Pattern | Use Case | Resume? |
|---|---|---|
| Basic | Provide implementation | Yes |
| Early return | Validation, errors | No |
| Reader | Configuration | Yes |
| Writer | Logging, accumulation | Yes |
| State | Counters, caches | Yes |
| Exception | Error handling | Sometimes |
Next
Chapter 7: Modules - Organizing code across files.