ForState: Named State Comprehensions
"The limits of my language mean the limits of my world."
-- Ludwig Wittgenstein, Tractatus Logico-Philosophicus
- 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 - Traversal-aware bulk operations and Iso-integrated state updates
- 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.
A Complete Workflow
Before diving into individual operations, here is a realistic example that shows how they combine. Each annotation in the comments maps to one of the API sections below:
@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. The rest of this chapter explains each operation in turn.
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(), through() | update(), modify(), fromThen(), zoom(), traverseOver(), modifyThrough(), modifyVia(), updateVia() | 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.
Operations Reference
"A complex system that works is invariably found to have evolved from a simple system that worked."
-- John Gall, The Systems Bible
The sections below group ForState operations by purpose. Each starts with the simplest form and builds towards more specialised variants.
Getting Started
Every ForState workflow begins with withState. The two overloads determine whether guards and pattern matching are available:
// 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.
You can also enter ForState mid-chain from a For comprehension. The toState() method constructs the state record from accumulated tuple values:
// 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 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();
Updating State
The most common operations are the simplest: setting a field to a known value, or transforming it with a pure function. These never involve effects and always succeed.
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();
Bringing in Effects
When a step needs to call an external service, validate input, or perform any computation that lives inside the monad, use the 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. Useful for logging, auditing, or side-effects that do not produce a value you need to keep:
ForState.withState(ioMonad, ioMonad.of(ctx))
.from(ctx -> logEvent("Processing " + ctx.orderId()))
.fromThen(ctx -> validateOrder(ctx.orderId()), validatedLens)
.yield();
Guarding and Matching
These operations are only available when the monad is a MonadZero (such as Maybe or List). They let you short-circuit the workflow when conditions are not met, or when data does not match an expected shape.
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
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();
Working with Collections
When the state contains a collection -- or is a collection -- these operations let you apply transformations to every element, optionally with effects.
traverse(collectionLens, traversal, function) -- applies an effectful function to each element in a collection field. The lens locates the collection within the state record; the traversal iterates over its elements:
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
traverseOver(traversal, function) -- like traverse, but operates directly on the state type itself when the state is the collection:
ForState.withState(maybeMonad, MAYBE.just(employeeList))
.traverseOver(Traversals.forList(),
emp -> emp.salary() > 0 ? MAYBE.just(emp) : MAYBE.nothing())
.yield();
modifyThrough(traversal, modifier) -- applies a pure function to each element. No monadic context required:
ForState.withState(idMonad, Id.of(employeeList))
.modifyThrough(Traversals.forList(),
emp -> new Employee(emp.name().toUpperCase(), emp.salary()))
.yield();
modifyThrough(traversal, lens, modifier) -- composes a traversal with a lens to modify a nested field within each element:
ForState.withState(idMonad, Id.of(employeeList))
.modifyThrough(Traversals.forList(), salaryLens, s -> s + 500)
.yield();
ForState.traverse(Lens, Traversal, Function) is an optics-based bulk update: it uses a lens to locate a collection field within the state record and a traversal optic to iterate over its elements. The result is written back into the state via the same lens.
The For comprehension offers a different form: For.traverse(Traverse, extractor, f), which is type-class-based. It uses a Traverse type-class instance to map an effectful function over a traversable structure extracted from the current tuple, adding the collected result as a new binding.
Both are useful; choose the one that matches your comprehension style. See Traverse Within Comprehensions for the For-based variant.
Converting Through Isos
When a field is stored in one representation but you need to reason about it in another -- cents versus dollars, Celsius versus Fahrenheit -- these operations let you work in the natural representation without manual conversion.
modifyVia(lens, iso, modifier) -- modifies a field by converting through an Iso, applying the modifier in the converted type, and converting back:
Iso<Integer, Double> centsToDollars = Iso.of(c -> c / 100.0, d -> (int) (d * 100));
ForState.withState(idMonad, Id.of(department))
.modifyVia(budgetLens, centsToDollars, dollars -> dollars * 1.1)
.yield();
updateVia(lens, iso, value) -- sets a field by converting the provided value through the Iso's reverseGet:
ForState.withState(idMonad, Id.of(department))
.updateVia(budgetLens, centsToDollars, 750.0)
.yield();
// Budget stored as 75000 cents
Zooming into Nested State
When part of your state is itself a record with its own fields, zoom narrows the scope so you can operate on the sub-record directly. This avoids composing lenses manually and makes the nesting visible in the code's indentation.
zoom(lens) -- narrows the state scope to a sub-record. 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
Every ForState workflow ends with yield. Two forms are available:
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");
- For Comprehension -- The tuple-based comprehension for short chains
- Optics Integration -- Traversal, Iso, and optics operations within comprehensions
- ForPath Comprehension -- Comprehensions returning concrete Effect Path types
- Lenses -- The optics that power ForState's named field access
- Isomorphisms -- Reversible type conversions used by
modifyViaandupdateVia - 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