Focus DSL: Type Class and Effect Integration

Run an update through an effect, fold a traversal into one value, and cross between Focus and Effect paths.

What You'll Learn

  • Validate or fetch inside an update with modifyF, choosing the effect by the Applicative you pass
  • Sum or join every value a traversal reaches with foldMap and a Monoid
  • Traverse a Kind<F, A> field with an Each built from its Traverse instance
  • Change only the elements that match with modifyWhen, and print what a path focused with traced
  • Cross between Focus and Effect paths in either direction, and predict when an AffinePath must name its absent case

The examples here use a flatter model than the previous pages: an Agency holding its Agents directly. One section needs a shape an Agency does not have and names its own: a RoleBox holding a Kind<ListKind.Witness, Role>. One variant of a sealed type is on Collections, Optionals and Sealed Types.

An ordinary modify takes A -> A. Once the transformation can fail, accumulate errors, or reach out to the network, it returns A wrapped in an effect, and the update has to thread that effect back out through the structure. That is what this page is about: the same paths, with effects along for the ride.


Effectful Modification with modifyF()

Why this matters

The path does not change. The same AgencyFocus.agents().via(AgentFocus.salary()) you use for a pure modifyAll serves an accumulating validation, an Either that reports the first problem, and an asynchronous fetch, with only the Applicative you hand it differing. Navigation and effect are separate concerns here, so adding validation to an update is not a rewrite of how you reach the data.

Every path type has modifyF(). The function returns the new value inside a Kind, and the whole modified structure comes back inside the same effect. The effect is chosen by the instance you pass, not by the path:

// A side-effecting read, deferred in IO
Kind<IOKind.Witness, Agency> deferred =
    AgencyFocus.name()
        .modifyF(
            name -> IO_OP.widen(IO.delay(() -> name.trim())), agency, Instances.monad(io()));

modifyF speaks Kind, not the concrete type

The function must return Kind<F, A> and the result is Kind<F, S>, so an IO goes in through IO_OP.widen(...) and a Validated comes out through VALIDATED.narrow(...). The four validation methods on Updates That Can Fail do that widening for you for Either, Maybe and Validated; reach for modifyF when the effect is something else, or when you already hold the Applicative.

Accumulating validation is the same call with a Validated applicative, and shows the one piece of ceremony worth knowing about: the witness has to be written out, because nothing in the argument list mentions List<String>.

// Validate every agent's email, accumulating all failures
Applicative<ValidatedKind.Witness<List<String>>> applicative =
    Instances.validated(Semigroups.<String>list());

Kind<ValidatedKind.Witness<List<String>>, Agency> result =
    AgencyFocus.agents()
        .via(AgentFocus.email())
        .modifyF(email -> VALIDATED.widen(Fixture.validateEmail(email)), agency, applicative);

Validated<List<String>, Agency> validated = VALIDATED.narrow(result);
// Valid(agency) when every address holds; Invalid([...]) listing every one that does not

Fixture is the compiled example's own setup: sample records, hand-written optics and validators. Nothing in it is library API.


Monoid-Based Aggregation with foldMap()

TraversalPath folds every focused element into a single value through a Monoid:

TraversalPath<Agency, Agent> agents = AgencyFocus.agents();

int payroll = agents.foldMap(Monoids.integerAddition(), Agent::salary, agency);
// 115000

String roster = agents.foldMap(Monoids.string(), Agent::name, agency);
// "AliceBob"

fold(monoid, source) is the same operation when the focused type is already the monoid's type, except that a null focus counts as the monoid's empty value.


Generic Collection Traversal with fromTraverse

When a field holds Kind<F, A> rather than a plain collection, .each(...) steps into it with an Each built from a Traverse instance:

FocusPath<RoleBox, Kind<ListKind.Witness, Role>> rolesPath = FocusPath.of(Fixture.rolesLens);

TraversalPath<RoleBox, Role> allRoles =
    rolesPath.each(EachInstances.fromTraverse(ListTraverse.INSTANCE));

List<Role> roles = allRoles.getAll(roleBox);
RoleBox promoted = allRoles.modifyAll(Fixture::promote, roleBox);

EachInstances.fromTraverse takes the witness F from the Traverse and the element type from the path, so the chain spells out neither. (A witness is the marker type that stands in for the higher-kinded F; see How the Encoding Works if the term is new.)

Field shapeUse
List<T>, Set<T>, Collection<T>each(EachInstances.listEach()), each(EachInstances.setEach()) or each(EachInstances.collectionEach()), already applied by the generated method
Kind<F, T> on an annotated recordnothing: the processor applies each(EachInstances.fromTraverse(...)) for you
Kind<F, T> behind a hand-written lenseach(EachInstances.fromTraverse(SomeTraverse.INSTANCE))

See Also

When the Kind<F, A> field is on a @GenerateFocus record, the processor recognises the witness and generates the traversal itself. See Kind Field Support.


Conditional Modification with modifyWhen()

modifyWhen applies the transformation only to elements that satisfy a predicate, leaving the rest untouched:

Agency afterRise =
    AgencyFocus.agents()
        .modifyWhen(
            e -> e.salary() < 58000,
            e -> new Agent(e.name(), e.email(), e.nickname(), e.salary() + 2000),
            agency);

It is filter(...).modifyAll(...) with one fewer intermediate, and it reads as the business rule it encodes.


Path Debugging with traced()

traced() returns the same path with an observer attached, so you can see what a chain actually focused without dismantling it:

TraversalPath<Agency, Agent> traced =
    AgencyFocus.agents()
        .traced((source, found) -> System.out.println("focused " + found.size() + " agents"));

List<Agent> agents = traced.getAll(agency);

The observer receives the focused values in the shape the path guarantees: an A for FocusPath, an Optional<A> for AffinePath, and a List<A> for TraversalPath.


Bridging to Effect Paths

Focus paths and Effect paths share the via composition operator but navigate different domains: one moves through structure, the other through failure. The bridge runs both ways.

What the crossing costs is set by the effect, not by the direction of travel. A FocusPath always has one focus, so it enters an effect as a success either way, unless that focus may be null (see A focus that may be null). An AffinePath may have none, and then the effect decides: Maybe and Optional already model absence, so they take it as it comes. The failure-carrying effects have no such slot, so they want the absent case named going in (toEitherPath an error, toTryPath a Supplier) and equally coming back, which is why EitherPath.focus(path, error) below asks for one too:

flowchart TD
    accTitle: Crossing from a Focus path into an effect
    accDescr: A FocusPath, always one focus, crosses with toMaybePath, toEitherPath, toTryPath or toIdPath. A TraversalPath, zero or more, crosses with toListPath, toStreamPath or toMaybePath. An AffinePath, which may be absent, crosses into Maybe or Optional as it is, while Either and Try need the absent case named. All three arrive in the effects domain, where via and recoverWith apply.
    P{"Which path is<br/>crossing over?"}
    P -->|"FocusPath:<br/>always one focus"| S["toMaybePath,<br/>toEitherPath,<br/>toTryPath, toIdPath"]
    P -->|"TraversalPath:<br/>zero or more"| L["toListPath,<br/>toStreamPath,<br/>toMaybePath"]
    P -->|"AffinePath:<br/>may be absent"| N["Maybe, Optional:<br/>take absence as is;<br/>Either, Try:<br/>need it named"]
    S --> E["in the effects domain:<br/>via, recoverWith, ..."]
    L --> E
    N --> E

    classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
    class P decision
    class S,L,N tier
    class E wire

Direction 1: Focus Path to Effect Path

Extract a value with optics and continue in an effect pipeline. A FocusPath always has one focus. Its one-argument toEitherPath, toTryPath and toIdPath take a focus that is not null, and toMaybePath gives Just unless it is null. An AffinePath may be empty, so its bridges take the value to use when it is:

// FocusPath: always present
MaybePath<String> name = AgencyFocus.name().toMaybePath(agency);
EitherPath<String, String> alwaysRight = AgencyFocus.name().toEitherPath(agency);

// AffinePath: absence is a real outcome, so name it
EitherPath<String, String> nickname =
    AgentFocus.nickname().toEitherPath(alice, "No nickname on file");
// Left("No nickname on file"), because Alice has none

Bridge methods on FocusPath:

MethodReturn TypeDescription
toMaybePath(S)MaybePath<A>Just(value), or Nothing for a null focus
toEitherPath(S)EitherPath<E, A>Right(value); throws for a null focus
toEitherPath(S, E)EitherPath<E, A>Right(value), or Left(error) for a null focus
toEitherPath(S, Supplier<E>)EitherPath<E, A>Right(value), or Left(error) built only for a null focus
toTryPath(S)TryPath<A>Success(value); throws for a null focus
toTryPath(S, Supplier<Throwable>)TryPath<A>Success(value), or Failure for a null focus
toIdPath(S)IdPath<A>Trivial effect wrapper

Bridge methods on AffinePath:

MethodReturn TypeDescription
toMaybePath(S)MaybePath<A>Just if present, Nothing otherwise
toEitherPath(S, E)EitherPath<E, A>Right if present, Left(error) otherwise
toEitherPath(S, Supplier<E>)EitherPath<E, A>Right if present, Left(error) built only when absent
toTryPath(S, Supplier<Throwable>)TryPath<A>Success or Failure
toOptionalPath(S)OptionalPath<A>Wraps in the Java Optional effect

Bridge methods on TraversalPath:

MethodReturn TypeDescription
toListPath(S)ListPath<A>The non-null focused values as a list
toNonDetPath(S)NonDetPath<A>The non-null values, combined as a Cartesian product
toStreamPath(S)StreamPath<A>The non-null values, streamed again on each run
toVStreamPath(S)VStreamPath<A>Virtual-thread stream of the non-null values
toMaybePath(S)MaybePath<A>The first value, or Nothing if there is none or it is null

A focus that may be null

FocusPath.of over a generated lens to a @Nullable field gives a path with one focus that may be null. (A generated Focus class widens such a field to an AffinePath already.) An Effect Path's value type is non-null, so each bridge says what a null focus becomes:

BridgeA null focus becomes
FocusPath.toMaybePathNothing
FocusPath.toEitherPath(S, E), or its Supplier formLeft(error), the supplier called only then
FocusPath.toTryPath(S, Supplier<Throwable>)Failure
TraversalPath.toListPath, toNonDetPath, toStreamPath, toVStreamPathLeft out of the result
TraversalPath.foldThe monoid's empty value
TraversalPath.toMaybePathNothing when it is the first focus

find(Objects::nonNull, source) gives the first focus that is not null, and getAll keeps every focus, where position matters.

The one-argument toEitherPath, toTryPath and toIdPath take only a focus that is not null. A Right, a Success and an IdPath always hold a value, so a null focus throws NullPointerException, and the message names the form to use instead. Under a nullness checker such as NullAway, their result types cannot be written for a nullable focus, though var hides that. For a focus that may be null, use the forms that take an error, or toMaybePath.

FocusPath<Prospect, @Nullable String> phone = FocusPath.of(ProspectLenses.phone());

EitherPath<String, String> reachable = phone.toEitherPath(carol, "No phone on file");
// Left("No phone on file"), because Carol has none

ListPath<String> named = TraversalPath.of(Traversals.<@Nullable String>forList()).toListPath(aliases);
// holds Ace and Bee: the null between them is left out

Direction 2: EffectPath.focus()

When a service call has already put the structure inside an effect, focus() navigates without unwrapping:

EitherPath<String, Agency> loaded = Path.right(agency);

EitherPath<String, String> agencyName = loaded.focus(AgencyFocus.name());

// An AffinePath needs the error for the absent case
EitherPath<String, String> firstNickname =
    loaded
        .focus(AgencyFocus.agents().headOption(), "No agents")
        .focus(AgentFocus.nickname(), "No nickname on file");

focus() signatures:

Effect TypeWith a FocusPathWith an AffinePath
MaybePath<A>MaybePath<B>MaybePath<B>
EitherPath<E, A>EitherPath<E, B>EitherPath<E, B>, given an E
TryPath<A>TryPath<B>TryPath<B>, given a Supplier<Throwable>
IOPath<A>IOPath<B>IOPath<B>, given a Supplier<RuntimeException>
ValidationPath<E, A>ValidationPath<E, B>ValidationPath<E, B>, given an E
OptionalPath<A>OptionalPath<B>OptionalPath<B>
IdPath<A>IdPath<B>MaybePath<B>

IdPath is the one row that changes effect: Id has nowhere to record an absent focus, so focusing an AffinePath through one hands back a MaybePath.

A FocusPath whose focus may be null reads the null as absent in the MaybePath and OptionalPath rows. The other rows take only a non-null focus: EitherPath, ValidationPath and IdPath throw on a null, TryPath gives a Failure, and IOPath fails when it runs. Pass path.nullable() to the AffinePath column instead, which reads a null focus as absent in every row.

Which Direction?

Start in the optics domain when you hold the data and want an effect pipeline: extract a value, then validate or fetch.

Start in the effects domain when a call has already returned an effect and you want to drill into its payload.

// Optics first: extract, then validate
EitherPath<String, String> checked =
    AgentFocus.nickname()
        .toEitherPath(bob, "No nickname on file")
        .via(nick -> nick.length() >= 3 ? Path.right(nick) : Path.left("Nickname too short"));

// Effect first: navigate what the call returned
EitherPath<String, Integer> salary =
    Path.<String, Agent>right(bob).focus(AgentFocus.salary());

Key Takeaways

  • modifyF is the effectful modify. The instance you pass picks the effect; the path is unchanged. Widen going in, narrow coming out.
  • foldMap turns a traversal into a query. Any Monoid will do, so sums, joins and set unions are the same call.
  • EachInstances.fromTraverse makes any Traverse an Each. .each(EachInstances.fromTraverse(traverse)) steps into a Kind<F, A> field as .each(EachInstances.listEach()) steps into a List.
  • The bridge runs both ways, and the effect sets the toll. toMaybePath/toEitherPath/toTryPath move optics results into an effect; focus() moves optic navigation inside one. In either direction an AffinePath meeting a failure-carrying effect must name the absent case, as must a focus that may be null, while Maybe and Optional take absence as it comes.

See Also


Previous: The Focus DSL in Depth Next: Custom Containers and Code Generation