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.
- Validate or fetch inside an update with
modifyF, choosing the effect by theApplicativeyou pass - Sum or join every value a traversal reaches with
foldMapand aMonoid - Traverse a
Kind<F, A>field with anEachbuilt from itsTraverseinstance - Change only the elements that match with
modifyWhen, and print what a path focused withtraced - Cross between Focus and Effect paths in either direction, and predict when an
AffinePathmust 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()
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()));
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 shape | Use |
|---|---|
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 record | nothing: the processor applies each(EachInstances.fromTraverse(...)) for you |
Kind<F, T> behind a hand-written lens | each(EachInstances.fromTraverse(SomeTraverse.INSTANCE)) |
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:
| Method | Return Type | Description |
|---|---|---|
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:
| Method | Return Type | Description |
|---|---|---|
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:
| Method | Return Type | Description |
|---|---|---|
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:
| Bridge | A null focus becomes |
|---|---|
FocusPath.toMaybePath | Nothing |
FocusPath.toEitherPath(S, E), or its Supplier form | Left(error), the supplier called only then |
FocusPath.toTryPath(S, Supplier<Throwable>) | Failure |
TraversalPath.toListPath, toNonDetPath, toStreamPath, toVStreamPath | Left out of the result |
TraversalPath.fold | The monoid's empty value |
TraversalPath.toMaybePath | Nothing 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 Type | With a FocusPath | With 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());
modifyFis the effectfulmodify. The instance you pass picks the effect; the path is unchanged. Widen going in, narrow coming out.foldMapturns a traversal into a query. AnyMonoidwill do, so sums, joins and set unions are the same call.EachInstances.fromTraversemakes anyTraverseanEach..each(EachInstances.fromTraverse(traverse))steps into aKind<F, A>field as.each(EachInstances.listEach())steps into aList.- The bridge runs both ways, and the effect sets the toll.
toMaybePath/toEitherPath/toTryPathmove optics results into an effect;focus()moves optic navigation inside one. In either direction anAffinePathmeeting a failure-carrying effect must name the absent case, as must a focus that may be null, whileMaybeandOptionaltake absence as it comes.
- Effect Path Overview: railway model and effect composition
- Focus-Effect Integration: the complete bridging guide
- Capability Interfaces: the powers behind effect operations
- Updates That Can Fail: validation-aware modification without hand-wiring an
Applicative
Previous: The Focus DSL in Depth Next: Custom Containers and Code Generation