ForState: Named State Comprehensions

"The limits of my language mean the limits of my world."

-- Ludwig Wittgenstein, Tractatus Logico-Philosophicus

What You'll Learn

  • Why ForState is 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, and zoom
  • Traversal-aware bulk operations and Iso-integrated state updates
  • Side-by-side comparison of For vs ForState for the same workflow
  • Choosing between For, ForPath, and ForState

See Example Code:


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() and t._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 -- shippingLens targets Integer; you cannot accidentally store a String.
  • 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.

Use @GenerateLenses

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

CriterionForForStateForPath
Steps1-33+1-3
NamingTuple positionsRecord fieldsTuple positions
Arity limit12Unlimited12
Opticsfocus(), match(), through()update(), modify(), fromThen(), zoom(), traverseOver(), modifyThrough(), modifyVia(), updateVia()focus(), match()
Guardswhen() (MonadZero)when(), matchThen() (MonadZero)when() (MonadZero)
ReturnsKind<M, R>Kind<M, S> or Kind<M, R>Concrete Path type
Best forSimple linear chainsComplex stateful workflowsEffect Path API users

Guidance:

  • Use For when you have a short, linear chain of 1-3 bindings where tuple access is clear.
  • Use For then toState() 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 ForState when 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 ForPath when you are working with the Effect Path API and want concrete Path return types.

ForState subsumes For for complex workflows

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();

Two Kinds of Traverse

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");

See Also


Further Reading

  • 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