Ambient Context
A common pain point in functional code: threading "request-scoped" values —
the current user, a request ID, feature flags — through every layer of the
call tree. Effects let you declare those values as ambient capabilities
instead. Deep functions read them via !, the request boundary installs a
handler once, and the type system still tracks the dependency through
needs.
This is the classic Reader/Writer monad pattern, expressed as algebraic
effects. We'll show both: an ambient source (Reader) for pulling values
in, and an ambient sink (Writer) for pushing events out — and why the
reader version is often clearer when you write it yourself, while the
writer is better off using Std.Control.
Reader: ambient read-only context
The reader pattern lets a deep function pull request-scoped values without having them passed as arguments. We'll build a small checkout flow where pricing depends on the current user's feature flags.
import Std.Set
record User {
id: Int,
name: String,
}
record Request {
id: String,
user: User,
flags: Set String,
}
effect RequestContext {
fun current_user : Unit -> User
fun request_id : Unit -> String
fun feature_enabled : (name: String) -> Bool
}Std.Control provides a generic Reader r effect with a single ask
operation, but defining our own with named operations reads better. With a
generic Reader Request, deep functions would write (ask! ()).user —
reaching into the environment each time. With a domain-specific effect,
they write current_user! () — the operation name carries the meaning. The
handler is also free to pull each value from anywhere (the request, env
vars, a config service) without callers needing to change.
Pure pricing helpers
Plain functions, no effects:
fun new_price : String -> Int
new_price "book" = 12
new_price "pen" = 2
new_price _ = 5
fun old_price : String -> Int
old_price "book" = 15
old_price "pen" = 3
old_price _ = 7Deep in the call tree
These functions never receive the user or feature flags as arguments.
They declare needs {RequestContext} and call the operations directly:
fun price_for : (item: String) -> Int needs {RequestContext}
price_for item =
if feature_enabled! "new_pricing" then new_price item
else old_price item
fun cart_total : List String -> Int needs {RequestContext}
cart_total [] = 0
cart_total (item :: rest) = price_for item + cart_total rest
fun checkout : (cart: List String) -> String needs {RequestContext}
checkout cart = {
let user = current_user! ()
let rid = request_id! ()
let total = cart_total cart
$"[req={rid}] {user.name} checked out {List.length cart} items, total {total}"
}price_for doesn't know what a Request is. checkout doesn't take a
user as input. They just ask for what they need, when they need it.
The request boundary
The handler supplies each operation by resume-ing with a value closed
over from req:
fun handle : (req: Request) -> String
handle req = {
checkout ["book", "pen", "mug"]
} with {
current_user () = resume req.user
request_id () = resume req.id
feature_enabled name = resume Set.member name req.flags
}That's it. The handler is installed once, at the boundary, and every
deeper call of current_user! () returns whatever the handler chose to
provide.
Running it
main () = {
let alice_req = Request {
id: "req-001",
user: User { id: 1, name: "alice" },
flags: Set.from_list ["new_pricing"],
}
let bob_req = Request {
id: "req-002",
user: User { id: 2, name: "bob" },
flags: Set.new (),
}
handle alice_req |> dbg
handle bob_req |> dbg
}Output:
[req=req-001] alice checked out 3 items, total 19
[req=req-002] bob checked out 3 items, total 25
Same checkout function, two different request contexts, no argument
plumbing. Alice gets the new pricing because her request's flags include
"new_pricing"; Bob gets the legacy path.
Writer: ambient append-only output
The dual problem: deep functions need to emit events — audit logs,
metrics, trace spans — without folding extra values into every return
type. The Tell effect from Std.Control provides this:
import Std.Control (Tell, run_writer)
record Event {
step: String,
detail: String,
} deriving (Debug)
fun reserve_inventory : (item: String) -> Unit needs {Tell Event}
reserve_inventory item =
tell! (Event { step: "inventory", detail: $"reserved 1x {item}" })
fun charge_card : (amount: Int) -> Unit needs {Tell Event}
charge_card amount =
tell! (Event { step: "billing", detail: $"charged {amount} cents" })
fun ship_order : (item: String) -> Unit needs {Tell Event}
ship_order item =
tell! (Event { step: "shipping", detail: $"queued {item} for shipment" })Each function declares needs {Tell Event} and pushes events via tell!
without changing its return type to (Result, List Event) or similar.
fun place_order : (item: String) -> (price: Int) -> String needs {Tell Event}
place_order item price = {
reserve_inventory item
charge_card price
ship_order item
$"order placed: {item}"
}place_order returns a plain String. The audit trail rides alongside
via the effect, not in the return type.
Recovering the log
run_writer runs the computation and returns a tuple of the result and
the accumulated log:
main () = {
let (result, audit) = run_writer (fun () -> place_order "book" 1299)
dbg result
dbg $"audit trail ({List.length audit} events):"
List.iter dbg audit
}Output:
order placed: book
audit trail (3 events):
Event { step: "inventory", detail: "reserved 1x book" }
Event { step: "billing", detail: "charged 1299 cents" }
Event { step: "shipping", detail: "queued book for shipment" }
Why we used Std.Control here
The reader's handler is trivial — every arm just resumes with a
closed-over value. But the writer's handler involves continuation logic
that's easy to get wrong: each tell! has to accumulate into a list as
the continuation unwinds, and the return clause has to seed the final
tuple. That's exactly the kind of thing the standard library should
handle:
fun run_writer : (f: Unit -> a needs {Tell w}) -> (a, List w)If you ever need a writer with non-list accumulation (a monoid sum, a
deduplicating set), you'd write your own variant. For a chronological
log, run_writer is the right reach.
A note on multiple instances
Saga's effects dispatch by operation name. Reader r and Tell w from
Std.Control each expose a single operation (ask and tell), and
there's no way to disambiguate two instances in the same scope:
needs {Reader Config, Reader User} would leave ask! with no way to
pick which one to call. The generic forms only fit the single-instance
case.
When you need multiple readers or multiple write streams, the workaround is the same pattern this recipe uses for the reader half: define domain-specific effects with uniquely named operations.
# Instead of multiple writers of the same effect type...
needs {Tell LogEntry, Tell Metric, Tell AuditEvent}
# ...define separate effects:
effect Log { fun log : String -> Unit }
effect Metric { fun emit : Metric -> Unit }
effect Audit { fun audit : Event -> Unit }You give up the reusability of run_writer — each handler has to redo
the accumulator threading — but the trade is straightforward and
inevitable until named handler instances land.
When to roll your own vs reach for Std.Control
These two patterns illustrate a general guideline:
- Roll your own effect when the value is domain-specific. Multiple
related operations, named by what they mean (
current_user,feature_enabled,request_id). The handler is usually a few lines ofresume-with-a-value. - Reach for
Std.Controlwhen you want a single generic operation with non-trivial handler logic. Writer is the clearest case — the accumulator threading is fiddly enough that you don't want to redo it.
The two approaches compose freely. A project can use a custom
RequestContext for reading values and a Tell Event for emitting them
in the same handled scope, with both effects listed in needs.
Compared to monad transformers
In Haskell, the equivalent program would build a transformer stack:
ReaderT Config (WriterT [Event] (ExceptT String IO)) a, with explicit
lift . lift . ask to reach into the right layer.
Saga still stacks handlers lexically, and order matters because the innermost handler intercepts first. What you don't do is stack at the type level. Function signatures use a flat, order-independent effect set:
fun process : Request -> Response needs {Reader Config, Tell Event, Fail String}No lift, no transformer ordering puzzle, no nested type tower. Adding a
fourth effect is just adding it to needs and providing a handler — no
restructuring of the signature, no rewriting of any call site that
already uses the existing effects.
Full source
Reader
import Std.Set
record User {
id: Int,
name: String,
}
record Request {
id: String,
user: User,
flags: Set String,
}
effect RequestContext {
fun current_user : Unit -> User
fun request_id : Unit -> String
fun feature_enabled : (name: String) -> Bool
}
fun new_price : String -> Int
new_price "book" = 12
new_price "pen" = 2
new_price _ = 5
fun old_price : String -> Int
old_price "book" = 15
old_price "pen" = 3
old_price _ = 7
fun price_for : (item: String) -> Int needs {RequestContext}
price_for item =
if feature_enabled! "new_pricing" then new_price item
else old_price item
fun cart_total : List String -> Int needs {RequestContext}
cart_total [] = 0
cart_total (item :: rest) = price_for item + cart_total rest
fun checkout : (cart: List String) -> String needs {RequestContext}
checkout cart = {
let user = current_user! ()
let rid = request_id! ()
let total = cart_total cart
$"[req={rid}] {user.name} checked out {List.length cart} items, total {total}"
}
fun handle : (req: Request) -> String
handle req = {
checkout ["book", "pen", "mug"]
} with {
current_user () = resume req.user
request_id () = resume req.id
feature_enabled name = resume Set.member name req.flags
}
main () = {
let alice_req = Request {
id: "req-001",
user: User { id: 1, name: "alice" },
flags: Set.from_list ["new_pricing"],
}
let bob_req = Request {
id: "req-002",
user: User { id: 2, name: "bob" },
flags: Set.new (),
}
handle alice_req |> dbg
handle bob_req |> dbg
}Writer
import Std.Control (Tell, run_writer)
record Event {
step: String,
detail: String,
} deriving (Debug)
fun reserve_inventory : (item: String) -> Unit needs {Tell Event}
reserve_inventory item =
tell! (Event { step: "inventory", detail: $"reserved 1x {item}" })
fun charge_card : (amount: Int) -> Unit needs {Tell Event}
charge_card amount =
tell! (Event { step: "billing", detail: $"charged {amount} cents" })
fun ship_order : (item: String) -> Unit needs {Tell Event}
ship_order item =
tell! (Event { step: "shipping", detail: $"queued {item} for shipment" })
fun place_order : (item: String) -> (price: Int) -> String needs {Tell Event}
place_order item price = {
reserve_inventory item
charge_card price
ship_order item
$"order placed: {item}"
}
main () = {
let (result, audit) = run_writer (fun () -> place_order "book" 1299)
dbg result
dbg $"audit trail ({List.length audit} events):"
List.iter dbg audit
}