ForState: Named State Comprehensions
- Why
ForStateis the recommended comprehension pattern for workflows with more than two or three steps - How named record fields (
ctx.user(),ctx.address()) replace fragile tuple positions (t._1(),t._2()) - The full ForState API:
update,modify,fromThen,when,matchThen,traverse, andzoom - Side-by-side comparison of
ForvsForStatefor the same workflow - Choosing between
For,ForPath, andForState
The Problem: Tuple Positions Do Not Scale
The For comprehension accumulates values in a tuple. At two or three bindings this is manageable. At five it becomes a maintenance hazard:
// For comprehension: a 6-step order workflow
For.from(monad, getUser(userId)) // a = user
.from(a -> lookupAddress(a)) // b = address
.from(t -> calculateShipping(t._2())) // c = shipping cost
.from(t -> checkInventory(t._1(), t._2())) // d = inventory
.let(t -> t._1().loyaltyDiscount()) // e = discount
.from(t -> confirmOrder(t._1(), t._4())) // f = confirmation
.yield((user, address, shipping, inventory, discount, confirmation) ->
buildReceipt(user, address, shipping, confirmation, discount));
The problems are immediate:
t._1()is meaningless -- you must mentally track that position 1 is the user, position 2 is the address, and so on.- Off-by-one errors are silent -- swapping
t._3()andt._4()compiles if the types happen to match. - Refactoring is fragile -- inserting a new step shifts every subsequent position.
- IDE support is limited -- auto-completion offers
_1()through_6(), but cannot tell you which is the address.
Every other language with for-comprehensions gives you named bindings. Java cannot, because a library cannot introduce new variable names into scope. The For builder works around this with tuples, which is adequate for short chains but painful at scale.
The Solution: A Record with Lenses
ForState takes a different approach. Instead of accumulating values in a growing tuple, you define a record that names every field in your workflow. Lenses provide type-safe read and write access to each field. The state record is threaded through each step automatically.
Here is the same order workflow rewritten with ForState:
// Define the workflow state with named fields
record OrderWorkflow(
User user,
Address address,
int shippingCents,
boolean inventoryOk,
double discount,
String confirmationId
) {}
// Lenses for each field (generated by @GenerateLenses or hand-written)
Lens<OrderWorkflow, User> userLens = ...;
Lens<OrderWorkflow, Address> addressLens = ...;
Lens<OrderWorkflow, Integer> shippingLens = ...;
Lens<OrderWorkflow, Boolean> inventoryLens = ...;
Lens<OrderWorkflow, Double> discountLens = ...;
Lens<OrderWorkflow, String> confirmationLens = ...;
// ForState comprehension: every field has a name
ForState.withState(monad, monad.of(initialWorkflow))
.fromThen(ctx -> getUser(ctx.user().id()), userLens)
.fromThen(ctx -> lookupAddress(ctx.user()), addressLens)
.fromThen(ctx -> calculateShipping(ctx.address()), shippingLens)
.fromThen(ctx -> checkInventory(ctx.user(), ctx.address()), inventoryLens)
.modify(discountLens, _ -> ctx.user().loyaltyDiscount())
.fromThen(ctx -> confirmOrder(ctx.user(), ctx.inventoryOk()), confirmationLens)
.yield(ctx -> buildReceipt(
ctx.user(), ctx.address(), ctx.shippingCents(),
ctx.confirmationId(), ctx.discount()));
The advantages are substantial:
- Every value has a name --
ctx.user()is unambiguous;t._1()is not. - The compiler catches misuse --
shippingLenstargetsInteger; you cannot accidentally store aString. - Refactoring is safe -- adding a new field to the record does not shift any existing accessor.
- IDE auto-completion works -- typing
ctx.lists every named field. - No arity limit -- the record can have any number of fields.
Annotate your state record with @GenerateLenses and the annotation processor generates all lenses automatically. No hand-written boilerplate needed.
@GenerateLenses
record OrderWorkflow(User user, Address address, int shippingCents, ...) {}
// Generated: OrderWorkflowLenses.user(), .address(), .shippingCents(), etc.
Side-by-Side: For vs ForState
The following comparison uses a realistic four-step workflow to highlight the difference.
With For (tuple-based)
Kind<MaybeKind.Witness, String> result =
For.from(maybeMonad, fetchUser(userId))
.from(user -> fetchPreferences(user))
.from(t -> calculateOffer(t._1(), t._2()))
.from(t -> applyDiscount(t._3(), t._1()))
.yield((user, prefs, offer, receipt) ->
formatReceipt(user, receipt));
At step 3, you must remember that t._1() is the user and t._2() is the preferences. At step 4, t._3() is the offer and t._1() is still the user -- but only if you count correctly.
With ForState (named fields)
@GenerateLenses
record OfferWorkflow(User user, Preferences prefs, Offer offer, String receipt) {}
Kind<MaybeKind.Witness, String> result =
ForState.withState(maybeMonad, MAYBE.just(initialWorkflow))
.fromThen(ctx -> fetchUser(userId), userLens)
.fromThen(ctx -> fetchPreferences(ctx.user()), prefsLens)
.fromThen(ctx -> calculateOffer(ctx.user(), ctx.prefs()), offerLens)
.fromThen(ctx -> applyDiscount(ctx.offer(), ctx.user()), receiptLens)
.yield(ctx -> formatReceipt(ctx.user(), ctx.receipt()));
Every intermediate reference is a named accessor. The intent is visible at a glance.
When to Use Which
| Criterion | For | ForState | ForPath |
|---|---|---|---|
| Steps | 1-3 | 3+ | 1-3 |
| Naming | Tuple positions | Record fields | Tuple positions |
| Arity limit | 12 | Unlimited | 12 |
| Optics | focus(), match() | update(), modify(), fromThen(), zoom() | focus(), match() |
| Guards | when() (MonadZero) | when(), matchThen() (MonadZero) | when() (MonadZero) |
| Returns | Kind<M, R> | Kind<M, S> or Kind<M, R> | Concrete Path type |
| Best for | Simple linear chains | Complex stateful workflows | Effect Path API users |
Guidance:
- Use
Forwhen you have a short, linear chain of 1-3 bindings where tuple access is clear. - Use
ForthentoState()when your workflow starts by gathering values (fetching, computing) and then needs named-field state management for the remaining steps. The bridge gives you concise accumulation followed by structured updates. - Use
ForStatewhen your workflow has more than two or three steps, when you need to refer back to earlier values by name, or when the workflow involves state that evolves over multiple steps. - Use
ForPathwhen you are working with the Effect Path API and want concrete Path return types.
Any workflow that can be expressed with For can be expressed with ForState. The reverse is not true: ForState supports unlimited fields, named access, zoom, and traverse operations that For cannot provide. For complex workflows, ForState is the recommended choice.
API Reference
Entry Points
// Standard entry point (any Monad)
ForState.withState(Monad<M> monad, Kind<M, S> initialState) → Steps<M, S>
// Filterable entry point (MonadZero adds when() and matchThen())
ForState.withState(MonadZero<M> monad, Kind<M, S> initialState) → FilterableSteps<M, S>
The MonadZero overload returns FilterableSteps, which extends Steps with guard and pattern-matching operations. This mirrors the For.from(MonadZero, Kind) design.
Bridging from For with toState()
You can also enter ForState from a For comprehension mid-chain. The toState() method on any MonadicSteps or FilterableSteps constructs the state record from the accumulated values and returns a ForState.Steps (or ForState.FilterableSteps for MonadZero monads):
// Bridge: accumulate values with For, then transition to ForState
Kind<IdKind.Witness, Dashboard> result =
For.from(idMonad, Id.of("Alice"))
.from(name -> Id.of(name.length()))
.toState((name, count) -> new Dashboard(name, count, false)) // bridge
.update(readyLens, true)
.modify(countLens, c -> c * 10)
.yield();
Both spread-style ((a, b) -> ...) and tuple-style (t -> ...) constructors are supported at all arities (1 through 12). This is particularly useful when the first few steps of a workflow fetch or compute values, and the remaining steps refine a structured record:
// With MonadZero: filtering is preserved across the bridge
Kind<MaybeKind.Witness, Dashboard> result =
For.from(maybeMonad, MAYBE.just("Grace"))
.from(name -> MAYBE.just(8))
.toState((name, count) -> new Dashboard(name, count, false))
.when(d -> d.count() > 5) // guard on the state
.update(readyLens, true)
.yield();
Pure State Updates
update(lens, value) -- sets a field to a constant value:
ForState.withState(idMonad, Id.of(ctx))
.update(statusLens, new Confirmed("CONF-123"))
.update(validatedLens, true)
.yield();
modify(lens, function) -- transforms a field using a pure function:
ForState.withState(idMonad, Id.of(ctx))
.modify(totalLens, total -> total + shippingCost)
.modify(nameLens, String::toUpperCase)
.yield();
Effectful Operations
fromThen(function, lens) -- runs a monadic computation and stores the result via a lens. This is the primary way to combine effects with state updates:
ForState.withState(eitherTMonad, eitherTMonad.of(ctx))
.fromThen(ctx -> validateOrder(ctx.orderId()), validatedLens)
.fromThen(ctx -> processPayment(ctx), confirmationLens)
.yield();
from(function) -- runs a monadic computation for its effect, without updating state:
ForState.withState(ioMonad, ioMonad.of(ctx))
.from(ctx -> logEvent("Processing " + ctx.orderId()))
.fromThen(ctx -> validateOrder(ctx.orderId()), validatedLens)
.yield();
Guards (MonadZero only)
when(predicate) -- short-circuits to the monad's zero element if the predicate fails:
ForState.withState(maybeMonad, MAYBE.just(ctx))
.when(ctx -> ctx.orderId() != null)
.when(ctx -> ctx.totalCents() > 0)
.fromThen(ctx -> processPayment(ctx), confirmationLens)
.yield();
// Returns Just(finalCtx) if all guards pass, Nothing if any fails
Pattern Matching (MonadZero only)
matchThen(sourceLens, prism, targetLens) -- extracts a field, matches with a prism, and stores the result. Short-circuits on mismatch:
sealed interface OrderStatus {
record Pending(String reason) implements OrderStatus {}
record Confirmed(String id) implements OrderStatus {}
}
ForState.withState(maybeMonad, MAYBE.just(ctx))
.matchThen(statusLens, confirmedIdPrism, extractedIdLens)
.yield();
// Just(ctx with extractedId set) if Confirmed, Nothing if Pending
matchThen(extractor, targetLens) -- a simpler variant using a function that returns Optional:
ForState.withState(maybeMonad, MAYBE.just(ctx))
.matchThen(
ctx -> ctx.status() instanceof Confirmed c
? Optional.of(c.id()) : Optional.empty(),
extractedIdLens)
.yield();
Bulk Operations
traverse(collectionLens, traversal, function) -- applies an effectful function to each element in a collection field:
Traversal<List<String>, String> listTraversal = Traversals.forList();
ForState.withState(maybeMonad, MAYBE.just(ctx))
.traverse(tagsLens, listTraversal, tag ->
isValid(tag) ? MAYBE.just(tag.toUpperCase()) : MAYBE.nothing())
.yield();
// Transforms all tags if all are valid; Nothing if any tag is invalid
Zooming into Nested State
zoom(lens) -- narrows the state scope to a sub-record. Operations within the zoom target the sub-state directly. endZoom() returns to the outer scope:
record Customer(String name, Address address, int loyaltyPoints) {}
record Address(String street, String city, String zip) {}
ForState.withState(idMonad, Id.of(customer))
.update(nameLens, "Alice Smith")
.zoom(addressLens) // enter Address scope
.update(streetLens, "456 Oak Ave") // operates on Address
.modify(zipLens, z -> z + "-5678")
.fromThen(a -> Id.of(a.city().toUpperCase()), cityLens)
.endZoom() // back to Customer scope
.modify(loyaltyLens, lp -> lp + 50)
.yield();
Yielding Results
yield() -- returns the final state as-is:
Kind<M, OrderWorkflow> finalState = ForState.withState(monad, ...)
.update(...)
.yield();
yield(projection) -- transforms the final state into a result:
Kind<M, String> receipt = ForState.withState(monad, ...)
.update(...)
.yield(ctx -> "Order " + ctx.orderId() + " confirmed");
Complete Example: Order Processing
This example combines all ForState features in a realistic workflow:
@GenerateLenses
record OrderContext(
String orderId,
OrderStatus status,
String extractedConfirmationId,
ShippingAddress shippingAddress,
List<String> tags,
int totalCents
) {}
MaybeMonad maybeMonad = MaybeMonad.INSTANCE;
Traversal<List<String>, String> listTraversal = Traversals.forList();
Kind<MaybeKind.Witness, String> result =
ForState.withState(maybeMonad, MAYBE.just(order))
.when(ctx -> ctx.orderId().startsWith("ORD")) // guard
.matchThen(statusLens, confirmedIdPrism, extractedIdLens) // pattern match
.traverse(tagsLens, listTraversal, // bulk transform
tag -> MAYBE.just(tag.toUpperCase()))
.zoom(addressLens) // narrow scope
.update(cityLens, "SPRINGFIELD")
.endZoom() // restore scope
.modify(totalLens, t -> t + 500) // add shipping
.yield(ctx -> String.format("Order %s confirmed (%s), tags: %s, total: $%.2f",
ctx.orderId(),
ctx.extractedConfirmationId(),
ctx.tags(),
ctx.totalCents() / 100.0));
Every value is accessed by name. The workflow reads top-to-bottom. Adding or removing a step does not affect any other step.
- For Comprehension -- The tuple-based comprehension for short chains
- ForPath Comprehension -- Comprehensions returning concrete Effect Path types
- Lenses -- The optics that power ForState's named field access
- ForState Tutorial -- Hands-on exercises
- Baeldung: Java Record Patterns -- Using Java records for immutable state representation
- Mojang/DataFixerUpper: DFU on GitHub -- Minecraft's Java library using optics for state transformation between game versions
Previous: For Comprehension | Next: Choosing Your Abstraction Level