ForState: Named State Comprehensions

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
  • 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.


When to Use Which

CriterionForForStateForPath
Steps1-33+1-3
NamingTuple positionsRecord fieldsTuple positions
Arity limit12Unlimited12
Opticsfocus(), match()update(), modify(), fromThen(), zoom()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.


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.


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