Tutorial

9. Reader, State and Writer

Some computations need configuration, an evolving state or accumulated output as well as their main result. Reader, State and Writer describe these needs as values that you can compose with the algebra operations from chapter 8.

Effect What passes between steps How to get the result
Reader[R] A shared environment of type R run borrows the environment and returns the payload
State[S] A state of type S, updated by each step run consumes the initial state and returns (payload, final_state)
Writer[W] Output combined by the monoid W run_writer returns (log, payload)

Reader and State are deferred: constructing a computation does not execute its steps. Writer over the default IdentityFamily combines its values eagerly; run_writer unwraps the result. Writer over a deferred base follows that base's evaluation rules.

Read configuration with Reader

A Reader computation uses the same environment throughout a run. This example prices three items from a configuration containing a unit price and shipping charge.

effects_reader.mojo Download
"""Read pricing configuration with Reader and scope a change with local."""
from fp.algebra import map
from fp.effects import Reader, ask, local, run
from std.testing import assert_equal


@fieldwise_init
struct TutorialPricing(Copyable):
    var unit_price: Int
    var shipping: Int


comptime PricingReader = Reader[TutorialPricing]


def free_shipping(pricing: TutorialPricing) -> TutorialPricing:
    return TutorialPricing(pricing.unit_price, 0)


def main() raises:
    var pricing = TutorialPricing(12, 5)
    var calls = 0

    def quote(var settings: TutorialPricing) {mut calls} -> Int:
        calls += 1
        return settings.unit_price * 3 + settings.shipping

    var standard = map[PricingReader](quote, ask[PricingReader]())
    assert_equal(calls, 0)
    print("before run: calls =", calls)
    var total = run[PricingReader](standard^, pricing)
    assert_equal(total, 41)
    assert_equal(calls, 1)
    print("standard total:", total)

    var quoted = map[PricingReader](quote, ask[PricingReader]())
    var promotion = local[PricingReader](free_shipping, quoted^)
    var discounted = run[PricingReader](promotion^, pricing)
    assert_equal(discounted, 36)
    assert_equal(pricing.shipping, 5)
    assert_equal(calls, 2)
    print("free shipping total:", discounted, "; original shipping:", pricing.shipping)

    print("after both runs: calls =", calls)

Run from the repository root pixi run mojo run -I src docs/examples/effects_reader.mojo

Output

before run: calls = 0
standard total: 41
free shipping total: 36 ; original shipping: 5
after both runs: calls = 2

PricingReader is an alias for Reader[TutorialPricing]. ask produces a computation that copies the environment into its payload, and map transforms that payload with quote. Because ask copies, the configuration implements Copyable. Reader's run itself only borrows the environment.

Building standard leaves calls at zero. Running it with a unit price of 12 and shipping of 5 calls quote once and returns 12 * 3 + 5 = 41.

local runs a computation with a temporary environment. free_shipping keeps the unit price and changes shipping to zero, so the promotional quote is 36. The original configuration still has shipping of 5. The temporary environment exists only for that run; local rejects a base whose result would keep it.

Keep borrowed callbacks alive until the run

map[PricingReader](quote, ...) borrows the native closure. The computation must run while quote is alive and unmoved. Its captured calls variable must also remain alive and stable. In the example, both runs finish in the scope that owns the closure and counter.

To store a native callback by value, adapt it with as_unary; use as_unary(f^) when transferring a callback with owned captures. The computation then owns that callable value. Any references the callable captures still keep their own origin restrictions. Owning the callable does not extend the life of the values it borrows. These are caller obligations; the pinned compiler does not reject every invalid owner reassignment.

The same rules apply to native callbacks passed to flat_map and traverse over deferred Reader or State instances. See ownership and errors for the shared conventions.

Pass an evolving counter through State

State separates the value a step returns from the state passed to the next step. Reading a counter to format a ticket need not change that counter; allocating the next number does.

effects_state.mojo Download
"""Read, update and reset a counter through State computations."""
from fp.algebra import map, flat_map
from fp.effects import State, get, modify, put, run
from std.testing import assert_equal


comptime CounterState = State[Int]


def ticket(counter: Int) -> String:
    return "ticket " + String(counter)


def increment(counter: Int) -> Int:
    return counter + 1


def read_counter(var unused: NoneType) -> type_of(get[CounterState]()):
    return get[CounterState]()


def main() raises:
    var label = map[CounterState](ticket, get[CounterState]())
    var described = run[CounterState](label^, 10)
    assert_equal(described[0], "ticket 10")
    assert_equal(described[1], 10)
    print("read:", described[0], "; state =", described[1])

    var advance = modify[CounterState](increment)
    var next_ticket = flat_map[CounterState](read_counter, advance^)
    var advanced = run[CounterState](next_ticket^, 10)
    assert_equal(advanced[0], 11)
    assert_equal(advanced[1], 11)
    print("update: value =", advanced[0], "; state =", advanced[1])

    var reset = flat_map[CounterState](read_counter, put[CounterState](0))
    var cleared = run[CounterState](reset^, 10)
    assert_equal(cleared[0], 0)
    assert_equal(cleared[1], 0)
    print("reset: value =", cleared[0], "; state =", cleared[1])

Run from the repository root pixi run mojo run -I src docs/examples/effects_state.mojo

Output

read: ticket 10 ; state = 10
update: value = 11 ; state = 11
reset: value = 0 ; state = 0

get reads a copy of the current state. Mapping ticket over it returns the payload "ticket 10" and leaves the state at 10. run therefore returns ("ticket 10", 10).

modify(increment) changes the state and returns None. flat_map then calls read_counter, whose get sees the updated value. Starting from 10, the computation returns (11, 11). The helper's return annotation, type_of(get[CounterState]()), names the concrete deferred computation it returns, rather than the Int that a later run will produce.

put(0) replaces the state. Sequencing another get after it returns (0, 0). Each call to run in this example starts a separate computation from state 10; there is no global counter. To continue from a previous run, pass its final state as the next run's initial state.

State's run consumes both the computation and the initial state. An Int can be copied implicitly; move a state with owned resources using ^.

Accumulate output with Writer

Writer pairs a payload with output that can be combined. StringMonoid uses the empty string as its identity and concatenation as its combination, so logs are appended in execution order.

effects_writer.mojo Download
"""Accumulate a pricing log with Writer, then inspect and format it."""
from fp.algebra import flat_map, StringMonoid
from fp.effects import Writer, writer, tell, listen, censor, run_writer
from std.testing import assert_equal


comptime PricingWriter = Writer[StringMonoid]


def add_fee(amount: Int) -> type_of(writer[PricingWriter, Int](Tuple(String(), 0))):
    return writer[PricingWriter, Int](Tuple(String("fee +2"), amount + 2))


def bracket(var log: String) -> String:
    return "[" + log + "]"


def main() raises:
    var notice = run_writer[PricingWriter](tell[PricingWriter]("ready"))
    assert_equal(notice[0], "ready")
    comptime assert type_of(notice[1]) == NoneType
    print("notice:", notice[0])

    var initial = writer[PricingWriter, Int](Tuple(String("start; "), 3))
    var charged = flat_map[PricingWriter](add_fee, initial^)
    var observed = listen[PricingWriter](charged^)
    var formatted = censor[PricingWriter](bracket, observed^)
    var output = run_writer[PricingWriter](formatted^)
    assert_equal(output[0], "[start; fee +2]")
    assert_equal(output[1][0], 5)
    assert_equal(output[1][1], "start; fee +2")
    print("value:", output[1][0])
    print("log:", output[0])
    print("log copied by listen:", output[1][1])

Run from the repository root pixi run mojo run -I src docs/examples/effects_writer.mojo

Output

notice: ready
value: 5
log: [start; fee +2]
log copied by listen: start; fee +2

tell("ready") records a log with a None payload. It does not print anything; the example unwraps the value with run_writer and prints the log itself.

writer constructs a Writer value from (log, payload). The initial value has log "start; " and payload 3. flat_map(add_fee, ...) passes 3 to add_fee, which returns payload 5 and log "fee +2". Writer combines the logs into "start; fee +2".

listen keeps that log and pairs the payload with a copy of it. censor then formats the outer log with brackets. The final result is:

("[start; fee +2]", (5, "start; fee +2"))

The log copied by listen records the text before censor changed the outer log. Copying requires a Copyable log type. ListMonoid[T] is another built-in choice when the output should be a list of records rather than text.

Combine effects with monad transformers

A monad transformer adds an effect to a base Monad. StateT[M, S] adds state to M; ResultT[M, E] adds a stored error. State[S] is shorthand for StateT[IdentityFamily, S], with analogous definitions for Reader and Writer. Use lift to embed an existing base computation in a transformer layer.

The order of layers determines the shape of the result and what survives a failure:

state_result.mojo Download
"""Transformer order decides whether a stored failure returns the updated state."""
from fp.algebra import pure, flat_map
from fp.effects import State, StateT, ResultT, run, lift, put
from fp.algebra import ResultFamily
from fp.data import Result, Err
from std.testing import assert_equal

comptime DomainResult=Result[Int,String]
comptime Keep=ResultT[State[Int],String]
comptime Discard=StateT[ResultFamily[String],Int]

def failure()->DomainResult:
    return Err(String("rejected"))

# Each adapter places the same domain failure at its stack's base boundary.
def fail_keep(var unused:NoneType)->type_of(pure[State[Int]](failure())):
    return pure[State[Int]](failure())

def fail_discard(var unused:NoneType)->type_of(lift[Discard](failure())):
    return lift[Discard](failure())

def main() raises:
    var kept=run[State[Int]](flat_map[Keep](fail_keep,lift[Keep](put[State[Int]](15))),10)
    comptime assert type_of(kept)==Tuple[DomainResult,Int]
    ref result=kept[0]
    var state=kept[1]
    assert_equal(result.is_err(),True)
    assert_equal(state,15)
    print("ResultT[State]: failed, returned state =",state)

    var discarded=run[Discard](flat_map[Discard](fail_discard,put[Discard](15)),10)
    comptime assert type_of(discarded)==Result[Tuple[Int,Int],String]
    assert_equal(discarded.is_err(),True)
    print("StateT[Result]: failed, no state payload")

Run from the repository root pixi run mojo run -I src docs/examples/state_result.mojo

Output

ResultT[State]: failed, returned state = 15
StateT[Result]: failed, no state payload

Both computations set the state to 15 and then fail with Err("rejected"). ResultT[State[Int], String] returns (Err("rejected"), 15), so the caller can inspect the state reached before failure. Its outer Result is a value inside State's computation; run it with run[State[Int]].

StateT[ResultFamily[String], Int] returns only Err("rejected"). A successful result would hold (payload, state) inside Ok, but an error has neither. Run this computation with its StateT instance. Neither ordering rolls back external mutations performed by callbacks.

Handle errors when a computation runs

A stored Err belongs to the Result layer. A native error raised by a callback still propagates as an exception with its original type. For Reader and State, that happens when run executes the callback, not when map or flat_map constructs the computation.

On Mojo 1.1, catch a raising deferred computation inside try/except; a plain raises on the enclosing function cannot propagate its error alias. The troubleshooting guide describes the diagnostic. Reusing a computation also requires an explicit copy when its type supports one; a consumed computation cannot be run again.

For other transformer combinations and their result shapes, see fp.effects. The algebra reference describes the shared operations, and the architecture chapter explains how deferred computations preserve their types, ownership and origins.