ForPath: For-Comprehensions with Effect Paths

"Though this be madness, yet there is method in't."

-- William Shakespeare, Hamlet

And so it is with for-comprehensions: what appears to be arcane syntax hides a deeply methodical approach to composing sequential operations.

What You'll Learn

  • How ForPath bridges the For comprehension system and the Effect Path API
  • Creating comprehensions directly with Path types (no manual Kind extraction)
  • Using generators (.from()), bindings (.let()), guards (.when()), and projections (.yield())
  • Choosing between ForPath and the standard For class

See Example Code:


The Problem: Bridging Two Worlds

The standard For class provides powerful for-comprehension syntax, but it operates on raw Kind<M, A> values and requires explicit Monad instances. When working with the Effect Path API, this creates friction:

// Using standard For with Path types requires extraction and rewrapping
Kind<MaybeKind.Witness, Integer> kindResult = For.from(maybeMonad, path1.run().kind())
    .from(a -> path2.run().kind())
    .yield((a, b) -> a + b);

MaybePath<Integer> result = Path.maybe(MAYBE.narrow(kindResult));

The intent is clear, but the ceremony obscures it. ForPath eliminates this friction:

// ForPath works directly with Path types
MaybePath<Integer> result = ForPath.from(path1)
    .from(a -> path2)
    .yield((a, b) -> a + b);

The comprehension accepts Path types and returns Path types. No manual extraction, no rewrapping, no boilerplate.


Entry Points

ForPath provides entry points for each supported Path type:

Path TypeEntry PointSupports when()
MaybePath<A>ForPath.from(maybePath)Yes
OptionalPath<A>ForPath.from(optionalPath)Yes
EitherPath<E, A>ForPath.from(eitherPath)No
TryPath<A>ForPath.from(tryPath)No
IOPath<A>ForPath.from(ioPath)No
VTaskPath<A>ForPath.from(vtaskPath)No
IdPath<A>ForPath.from(idPath)No
NonDetPath<A>ForPath.from(nonDetPath)Yes
GenericPath<F, A>ForPath.from(genericPath)Optional

All nine path types support up to 12 chained bindings. The when() guard operation is only available for Path types backed by MonadZero, which can represent emptiness or failure.


Core Operations

Generators: .from()

The .from() operation extracts a value from the current step and chains to a new Path-producing computation. This is the monadic bind (flatMap) in disguise.

MaybePath<String> result = ForPath.from(Path.just("Alice"))
    .from(name -> Path.just(name.length()))         // a = "Alice", b = 5
    .from(t -> Path.just(t._1() + ":" + t._2()))    // t is Tuple2<String, Integer>
    .yield((name, len, combined) -> combined);      // "Alice:5"

Each .from() adds a new value to the accumulating tuple, making all previous values available to subsequent steps.

Value Bindings: .let()

The .let() operation computes a pure value from accumulated results without introducing a new effect. It's equivalent to map that carries the value forward.

MaybePath<String> result = ForPath.from(Path.just(10))
    .let(a -> a * 2)                    // b = 20 (pure calculation)
    .let(t -> t._1() + t._2())          // c = 30 (can access tuple)
    .yield((a, b, c) -> "Sum: " + c);   // "Sum: 30"

Guards: .when()

For Path types with MonadZero (MaybePath, OptionalPath, NonDetPath), the .when() operation filters results. When the predicate returns false, the computation short-circuits to the monad's zero value (Nothing, empty, etc.).

MaybePath<Integer> evenOnly = ForPath.from(Path.just(4))
    .when(n -> n % 2 == 0)              // passes: 4 is even
    .yield(n -> n * 10);                // Just(40)

MaybePath<Integer> filtered = ForPath.from(Path.just(3))
    .when(n -> n % 2 == 0)              // fails: 3 is odd
    .yield(n -> n * 10);                // Nothing

Projection: .yield()

Every comprehension ends with .yield(), which maps the accumulated values to a final result. You can access values individually or as a tuple:

// Individual parameters
.yield((a, b, c) -> a + b + c)

// Or as a tuple for many values
.yield(t -> t._1() + t._2() + t._3())

Optics Integration

ForPath integrates with the Focus DSL for structural navigation within comprehensions.

Extracting with .focus()

The .focus() operation uses a FocusPath to extract a nested value:

record User(String name, Address address) {}
record Address(String city, String postcode) {}

// Create lenses for each field
Lens<User, Address> addressLens = Lens.of(
    User::address, (u, a) -> new User(u.name(), a));
Lens<Address, String> cityLens = Lens.of(
    Address::city, (a, c) -> new Address(c, a.postcode()));

// Compose paths with via() for nested access
FocusPath<User, Address> addressPath = FocusPath.of(addressLens);
FocusPath<User, String> userCityPath = addressPath.via(FocusPath.of(cityLens));

MaybePath<String> result = ForPath.from(Path.just(user))
    .focus(userCityPath)                        // extract city directly
    .yield((user, city) -> city.toUpperCase());

Alternatively, chain focus operations where the second takes a function:

FocusPath<User, Address> addressPath = FocusPath.of(addressLens);

MaybePath<String> result = ForPath.from(Path.just(user))
    .focus(addressPath)                         // extract address -> Steps2
    .focus(t -> t._2().city())                  // extract city from tuple
    .yield((user, address, city) -> city.toUpperCase());

Pattern Matching with .match()

The .match() operation uses an AffinePath for optional extraction. When the focus is absent, the comprehension short-circuits for MonadZero types:

sealed interface Result permits Success, Failure {}
record Success(String value) implements Result {}
record Failure(String error) implements Result {}

AffinePath<Result, Success> successPath = AffinePath.of(
    Affine.of(
        r -> r instanceof Success s ? Optional.of(s) : Optional.empty(),
        (r, s) -> s
    )
);

MaybePath<String> result = ForPath.from(Path.just((Result) new Success("data")))
    .match(successPath)                         // extract Success
    .yield((r, success) -> success.value().toUpperCase());
// Just("DATA")

MaybePath<String> empty = ForPath.from(Path.just((Result) new Failure("error")))
    .match(successPath)                         // fails to match
    .yield((r, success) -> success.value());
// Nothing

When to Use ForPath vs For

ScenarioUse
Working with Effect Path APIForPath
Need Path types as outputForPath
Working with raw Kind<M, A>For
Using monad transformers (StateT, EitherT)For
Custom monads without Path wrappersFor with GenericPath adapter

For monad transformer stacks, the standard For class remains the appropriate choice as it works directly with the transformer's Kind representation.


Key Takeaways

  • ForPath eliminates boilerplate when composing Path types in for-comprehension style
  • Entry points accept Path types directly and return Path types
  • All For operations are supported: from(), let(), when() (where applicable), yield()
  • par() expresses independent computations with true concurrency for VTaskPath
  • Optics integration via focus() and match() enables structural navigation
  • Type safety is preserved throughout the comprehension

See Also

Benchmarks

ForPath has dedicated JMH benchmarks measuring for-comprehension overhead compared to direct chaining. Key expectations:

  • ForPath vs direct chaining: 10-25% overhead — primarily from tuple allocation
  • let() vs from(): let() is ~20% faster as it avoids Path wrapping
  • ForPath overhead > 50% is a warning sign indicating tuple handling inefficiency
./gradlew :hkj-benchmarks:jmh --includes=".*ForPathVTaskBenchmark.*"

See Benchmarks & Performance for full details and how to interpret results.

Hands-On Learning

Practice parallel composition with ForPath in Tutorial 02: ForPath Parallel Composition (9 exercises, ~20 minutes).


Previous: Composition Patterns Next: ForPath Examples