Collections, Optionals and Sealed Types
Step into lists, maps, optionals and sealed variants from a generated path, and join a path to any optic.
- Step into a collection's elements, or pick one element by index or key
- Read an optional or nullable value as present or absent
- Focus one variant of a sealed type
- Tell which fields get a generated navigator, and spell the hop where none does
The code on this page is NavigationBook.java and its NavigationBookTest.java: the page includes them, so the build compiles and runs them.
NavigatorExample and ContainerNavigationExample run this page's navigators and container hops as longer programs.
The previous page gave you one method per record component. This page is about the hops you spell yourself. They step into a collection's elements or one of them, through an optional or nullable value, onto one variant of a sealed type, and onto an optic from somewhere else. How a field's container decides its path type, which the processor calls widening, is in The fine print at the end.
Collections
Traverse all elements
.each(...) steps from a collection into its elements. For a List, Set or Collection field the generated method has already applied the right one, which is why OrderFocus.lines() focuses each LineItem:
// Generated: FocusPath.of(lens).each(EachInstances.listEach())
TraversalPath<Order, LineItem> allLines = OrderFocus.lines();
List<LineItem> lines = allLines.getAll(order);
// Applying the Each yourself, starting from the lens to the whole list
TraversalPath<Order, LineItem> sameThing =
FocusPath.of(OrderLenses.lines()).each(EachInstances.listEach());
.each(Each): traverse with a custom Each instance
Every container takes an explicit Each: a List, a Set, a Collection, a Map, an array, a third-party collection. An Each is a small strategy object, as a Comparator is for sorting, that says how to visit a container's elements. Its type names the container, so .each(EachInstances.listEach()) does not compile on a path that focuses anything but a List. The generated method already does this for you: EachInstances.listEach() for a List field, EachInstances.setEach() for a Set, EachInstances.collectionEach() for a Collection. Hand-built paths have to say it themselves, and this works on FocusPath, AffinePath and TraversalPath alike. A price list keeps its prices in a Map keyed by SKU:
@GenerateLenses
@GenerateFocus
record PriceList(String name, Map<String, BigDecimal> prices) {}
// A Map field: traverse the values
TraversalPath<PriceList, BigDecimal> allPrices =
PriceListFocus.prices().each(EachInstances.mapValuesEach());
// An HKJ container held behind a hand-written lens
Lens<SavedForLater, Maybe<LineItem>> itemLens =
Lens.of(SavedForLater::item, (_, item) -> new SavedForLater(item));
TraversalPath<SavedForLater, LineItem> savedItem =
FocusPath.of(itemLens).each(EachExtensions.maybeEach());
SavedForLater holds a line a customer put aside, as an HKJ Maybe, and carries no annotation, so a hand-written lens reaches the Maybe and the hop names its Each.
For available Each instances and how to create your own, see Each.
import static org.higherkindedj.optics.each.EachInstances.listEach; turns .each(EachInstances.listEach()) into .each(listEach()), and FocusPaths.optionalSome and FocusPaths.mapAt shorten the same way. Import listAt, listHead and listLast from one class only, since FocusPaths, Affines and Prisms each have their own.
Access by index
.via(FocusPaths.listAt(index)) focuses a single element of a list, and .via(FocusPaths.mapAt(key)) a single value of a map. On a FocusPath or an AffinePath both give an AffinePath, because the position may not be occupied; on a TraversalPath the result stays a TraversalPath.
The generated Focus class has exactly one method per record component, so there is no generated order.line(0) accessor. Index from the path that still focuses the list. The element type comes from that path, so the result composes further with no type witness.
// A List field: start from the lens, because the generated path is element-level
AffinePath<Order, LineItem> firstLine =
FocusPath.of(OrderLenses.lines()).via(FocusPaths.listAt(0));
Optional<LineItem> first = firstLine.getOptional(order); // empty if out of bounds
// Or narrow the generated traversal to its first element. Mind the asymmetry:
// headOption reads the first element but writes to all of them
AffinePath<Order, LineItem> alsoFirst = OrderFocus.lines().headOption();
// A Map field: the generated path still focuses the whole map, so mapAt applies
AffinePath<PriceList, BigDecimal> lamp = PriceListFocus.prices().via(FocusPaths.mapAt("LAMP"));
Optional<BigDecimal> lampPrice = lamp.getOptional(priceList);
OrderFocus.lines() focuses each LineItem; FocusPath.of(OrderLenses.lines()) focuses the List<LineItem>. Anything that operates on the container as a whole (indexing, ListPrisms, a custom list-level optic) has to start from the second. Reach for the generated path when you want to act on every element, and for the lens when you want to act on the collection.
Optional and nullable values
Unwrap an Optional
An Optional<T> field is unwrapped for you: the generated method applies .some(FocusPaths.optionalSome()) and returns an AffinePath. Apply it yourself when the Optional sits behind a hand-written lens.
.nullable(): read a null as absent
For a field that may be null, .nullable() turns null into absence. A contact imported from an older system has a nickname that may be null, and nothing says so:
// From an older system: its nickname may be null, and nothing says so
@GenerateLenses
@GenerateFocus
record LegacyContact(String name, String nickname) {}
FocusPath<LegacyContact, String> rawPath = LegacyContactFocus.nickname();
AffinePath<LegacyContact, String> safePath = rawPath.nullable();
Optional<String> missing = safePath.getOptional(new LegacyContact("Charles", null));
Optional<String> present = safePath.getOptional(new LegacyContact("Grace", "Amazing Grace"));
missing is empty, because Charles's nickname is null, and present holds "Amazing Grace".
Annotate the component and the generated method hands you the AffinePath already. The processor recognises six annotations named @Nullable, from org.jspecify.annotations, javax.annotation (JSR-305), jakarta.annotation, org.jetbrains.annotations, androidx.annotation and edu.umd.cs.findbugs.annotations (SpotBugs). It does not read Spring's org.springframework.lang.Nullable, so a field carrying that one gives a FocusPath, and you chain .nullable() as for a field nobody annotated, such as LegacyContact's nickname. Where a @Nullable counts has the placement rules.
One variant of a sealed type
A sealed field's path focuses the whole value, and .via(...) with a prism narrows it to one variant. For a sealed interface you own, @GeneratePrisms names each variant; for a hierarchy you do not own, AffinePath.instanceOf(Class) matches by runtime type:
@GeneratePrisms
public sealed interface Payment permits Card, Bank {}
@GenerateFocus
public record Card(String pan) implements Payment {}
@GenerateFocus
public record Bank(String iban) implements Payment {}
A customer's payment history holds every payment they have made, of either kind:
@GenerateFocus
record PaymentHistory(UUID customerId, List<Payment> payments) {}
// A sealed type you own: @GeneratePrisms names each variant
TraversalPath<PaymentHistory, String> cardPans =
PaymentHistoryFocus.payments().via(PaymentPrisms.card()).via(CardFocus.pan());
List<String> pans = cardPans.getAll(history); // the bank payments are skipped
PaymentHistory masked =
cardPans.modifyAll(pan -> "**** " + pan.substring(pan.length() - 4), history);
// A sealed type you do not own: AffinePath.instanceOf matches by runtime type
TraversalPath<PaymentHistory, String> samePans =
PaymentHistoryFocus.payments().via(AffinePath.instanceOf(Card.class)).via(CardFocus.pan());
For a history of a card numbered 4242424242424242 and a bank payment, pans holds that one number, and masked holds the card as **** 4242 beside the bank payment as it was. Either route reads and modifies only when the variant fits. On a single sealed field, set still writes the variant whatever was there, as AffinePath warns.
Composition with existing optics
.via() composes a path with any optic, and the result has the wider of the two path types:
// Path + Lens = Path
FocusPath<Consignment, String> street =
FocusPath.of(ConsignmentLenses.to()).via(AddressLenses.street());
// Path + Prism or Affine = AffinePath
AffinePath<Order, LineItem> firstLine =
FocusPath.of(OrderLenses.lines()).via(ListPrisms.head());
// Path + Traversal = TraversalPath
TraversalPath<Order, LineItem> allLines =
FocusPath.of(OrderLenses.lines()).via(Traversals.forList());
.via() also accepts another Focus path, which is how you cross a type boundary the navigator did not cover. .via(LineItemFocus.sku()) is .via(LineItemFocus.sku().toLens()) with the ceremony removed, and it keeps the field names the path carries, which locate a failure in an edit that validates. The raw-optic overload drops them.
Fluent navigation with generated navigators
A field whose type is another @GenerateFocus record gets a navigator: the field's own path, with a method added for each field of the record it reaches. So the next hop is a method call rather than a .via(), and the navigator still goes wherever its path goes:
@GenerateLenses
@GenerateFocus
public record Consignment(UUID orderId, Address to, ConsignmentState state) {}
@GenerateLenses
@GenerateFocus
public record Address(String street, String city, String postcode) {}
// The navigator chains the next hop as a method call
String city = ConsignmentFocus.to().city().get(consignment);
// It is the path itself, so it goes wherever a path goes
FocusPath<Consignment, Address> to = ConsignmentFocus.to();
String same = to.via(AddressFocus.city()).get(consignment);
Which fields get a navigator
Not every field does, and knowing which is the difference between a chain that compiles and one that does not. Some container types, such as Map, Either and third-party collections, reach the processor through a service-provider interface (SPI) for container generators; the chart calls them SPI containers.
flowchart LR
accTitle: Which fields get a navigator
accDescr: A record annotated with GenerateFocus, whose Focus class was generated and whose type parameters the field names, gets a navigator. So does an Optional, Maybe, List, Set, Collection or Kind of such a record, and an SPI container of one that the declaring record widens. A generic record given a wildcard or no type arguments gets no navigator, so chain a lens you write with via. Anything else keeps a plain path chained with via.
F{"The field's<br/>type is..."}
F --- R1["a record with<br/>@GenerateFocus, its Focus<br/>class generated and each<br/>type parameter named"]
F --- R2["an Optional, Maybe, List,<br/>Set, Collection or Kind<br/>of such a record"]
F --- R3["a widened SPI<br/>container of<br/>such a record"]
F --- R4["a generic record<br/>given a wildcard or<br/>no type arguments"]
F --- R5["anything else"]
R1 --> N["Navigator<br/>chain with<br/>a method call"]
R2 --> N
R3 --> N
R4 --> Q["No navigator<br/>chain a lens you<br/>write with .via()"]
R5 --> P["Plain path<br/>chain with .via()"]
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
classDef step fill:#8caaee,stroke:#1e66f5,color:#232634
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
class F decision
class R1,R2,R3,R4,R5 step
class N,Q,P tier
A container of such records gets a navigator over the path its method already returns, so the next hop is a method call there too. An Optional, Maybe, List, Set or Collection is always stepped into: a List<LineItem> lines field gives you a LinesNavigator, which is a TraversalPath<Order, LineItem> with a method for each LineItem field.
// customer is a navigable record: a navigator, so .email() chains
String email = OrderFocus.customer().email().value().get(order);
// lines is a List of them: a navigator over every line, so .sku() chains too
TraversalPath<Order, String> skuPath = OrderFocus.lines().sku();
List<String> skus = skuPath.getAll(order);
An SPI container (a Map, an array, an Eclipse Collections ImmutableList, an Either) gets a navigator only where the record widens it. An Either is always widened; a Map or an array is widened under widenCollections, or when widenFields names the field. Containers nested in one another compose to the record they hold, so List<Optional<LineItem>> gets a navigator over each LineItem too. So does a Kind<F, A> field of such records. A record with type parameters of its own gets one when the field names a type for each of them, as A target with type parameters explains.
A chain keeps navigating where a hop widens the path. An order history holds a list of orders:
// A customer's past orders: a list of orders, each holding a list of lines
@GenerateFocus
record OrderHistory(List<Order> orders) {}
OrderFocus.customer() is a FocusPath, but orders().customer() reaches every order's customer, so it is a traversal. Each navigator has a form for each wider path type, nested in it as AffineForm and TraversalForm. So orders().customer() returns OrderFocus.CustomerNavigator.TraversalForm, and .email() chains:
// A hop into each order's list keeps the traversal, so lines() hands on its navigator
List<BigDecimal> prices = OrderHistoryFocus.orders().lines().price().getAll(history);
// A hop into one customer lands on a traversal too, so customer() hands on the traversal
// form of the order's customer navigator, and the chain goes on
OrderFocus.CustomerNavigator.TraversalForm<OrderHistory> customers =
OrderHistoryFocus.orders().customer();
List<String> emails = customers.email().value().getAll(history);
What a navigator provides
A navigator is its path, with one method added per field of the target type. Every method of its path type works on it directly, such as via, modifyF and traced. Here traced records each postcode it passes before the path normalises it, so a consignment to n1 1aa comes back addressed to N1 1AA:
List<String> seen = new ArrayList<>();
Consignment normalised =
ConsignmentFocus.to()
.traced((_, address) -> seen.add(address.postcode()))
.modify(
address -> AddressLenses.postcode().modify(String::toUpperCase, address),
consignment);
toPath() hands back the path without the navigation methods, where a type must name the plain path. Every other path method returns a plain path too, so after OrderFocus.lines().filter(...) the next hop is .via(LineItemFocus.price()).
When to use navigators
They are on by default. A navigable field gets up to three small classes: its navigator, and a form nested in it for each wider path type. Each is a subtype of the plain path it wraps, so code that names the path type keeps compiling, and your IDE can autocomplete the whole path.
Turn them off with @GenerateFocus(generateNavigators = false) when generated-code size matters more than fluent hops, or the structures are shallow enough that .via() costs nothing.
You can now step into a collection, read an optional or nullable value, pick one variant of a sealed type, and chain into another record with a navigator or .via(). The rest of this page is for a field that surprises you.
The fine print
The rest of this page is detail for an unusual field or build. It covers an SPI container, a list you take apart, a record in another module, and the annotation's navigator options. Then come a field named like a path method, two fields with one navigator name, a target with type parameters, where a @Nullable counts, and the rules by which a path widens.
.some(Affine): navigate SPI container types
Container types registered through the TraversableGenerator service-provider interface (SPI) that hold zero or one element take an Affine describing which side to focus. A stockroom, the one an order is picked from, keeps its stock by SKU and a name verified by a check, as an Either:
// The stockroom an order is picked from: stock by SKU, and a name verified by a check
@GenerateLenses
@GenerateFocus
record Stockroom(String name, Map<String, Integer> stock, Either<String, String> verifiedName) {}
// Either<String, String> field: the generated method already applies
// .some(Affines.eitherRight()), focusing the Right value
AffinePath<Stockroom, String> verified = StockroomFocus.verifiedName();
Optional<String> name = verified.getOptional(stockroom); // empty for a Left
Stockroom renamed =
verified.set("Northern", stockroom); // replaces a Left with Right("Northern")
Stockroom untouched = verified.modify(String::toUpperCase, stockroom); // a no-op on a Left
The following Affine instances cover HKJ's containers that hold zero or one value:
| Container type | Affine instance | Focuses on |
|---|---|---|
Either<L, R> | Affines.eitherRight() | The Right value |
Try<A> | Affines.trySuccess() | The Success value |
Validated<E, A> | Affines.validatedValid() | The Valid value |
Maybe<A> | Affines.just() | The Just value |
For a runnable example covering all container types, see ContainerNavigationExample.java.
List decomposition with ListPrisms
ListPrisms optics work on the list itself, so compose them onto a path that focuses the whole list:
FocusPath<Order, List<LineItem>> lines = FocusPath.of(OrderLenses.lines());
AffinePath<Order, LineItem> firstLine = lines.via(ListPrisms.head());
Optional<LineItem> first = firstLine.getOptional(order);
AffinePath<Order, LineItem> lastLine = lines.via(ListPrisms.last());
// Pattern match with cons (head, tail)
AffinePath<Order, Pair<LineItem, List<LineItem>>> consPath = lines.via(ListPrisms.cons());
Optional<List<LineItem>> tail = consPath.getOptional(order).map(Pair::second);
| ListPrisms Method | Type | Description |
|---|---|---|
ListPrisms.head() | Affine<List<A>, A> | Focus on first element |
ListPrisms.last() | Affine<List<A>, A> | Focus on last element |
ListPrisms.tail() | Affine<List<A>, List<A>> | Focus on all but first |
ListPrisms.init() | Affine<List<A>, List<A>> | Focus on all but last |
ListPrisms.cons() | Prism<List<A>, Pair<A, List<A>>> | Decompose as (head, tail) |
ListPrisms.snoc() | Prism<List<A>, Pair<List<A>, A>> | Decompose as (init, last) |
On a path focusing a list, compose the same decompositions with .via, from FocusPaths.listHead(), listLast(), listTail(), listInit(), listCons() and listSnoc().
For the full treatment, including stack-safe operations on large lists, see List Decomposition.
Navigators into a dependency
A target in a dependency is navigable too. The processor asks the field's type whether it carries @GenerateFocus, which is kept in the class file, so a record read from a jar is recognised as a sibling source file is. Navigating into it composes the Focus class that record's own module generated, reading each field's path type from the method that module published rather than working it out again, so a navigator never composes a method or path type the dependency did not publish, whichever processor version or generator plugins built it. An API module can therefore own the records, and each consuming module's Focus classes chain straight into them.
Three things can keep such a navigator from being generated in full, and the processor says which in a note against the field:
- The dependency did not run the processor. Its records carry the annotation but it generated no
Focusclasses, so there is nothing to compose, and the field gets no navigator. - A field names a type this module cannot see. A dependency's record may use a type from one of its dependencies that is not on this module's compile classpath. That field is left out of the navigator, and the rest are generated as usual.
- The dependency's
Focusclass does not match its record. A companion generated from an older version of the record, or a class of the same name the processor did not write, has no method a navigator can compose for some field. That field is left out, and rebuilding the dependency with the processor restores it.
Multi-module builds has the build-side detail.
Controlling navigator generation
Depth limiting: only a maxNavigatorDepth of 1 or less changes the generated code. It makes a navigator's own navigation methods return plain paths, so only the first hop chains fluently. A larger value, the default 3 included, does not stop a chain. Each hop into a navigable record returns the navigator that the Focus class of the record it leaves declares for that field, or its form for a wider path. Under the default, OrderFocus.customer().email() returns CustomerFocus.EmailNavigator.
@GenerateFocus(maxNavigatorDepth = 1)
record Order(
UUID id,
Customer customer,
List<LineItem> lines,
Instant placedAt,
Currency currency,
OrderStatus status) {}
// customer() returns a navigator
// customer().email() returns a plain path; compose further hops with .via()
Field filtering picks which fields are worth a navigator:
// Only these fields get one
@GenerateFocus(includeFields = {"delivery"})
record Contacts(Address billing, Address delivery, Address returns) {}
// All but these do
@GenerateFocus(excludeFields = {"referrer"})
record CustomerReferral(Customer referrer, Customer referred) {}
Set one list or the other. Beside a non-empty includeFields, excludeFields is never read, and the processor warns. Each name is checked against the record's components, so a typo is an error that offers the name you meant, rather than a navigator quietly missing. A name whose field has no navigator, such as a String, warns too.
A field named like a path method
A navigator already has its path's methods, so it cannot navigate to a field named like one that takes no arguments, such as last, head or segments. On a record that leaves navigators to the default, the field that reaches such a record keeps its plain path, with a note; on one that writes generateNavigators = true, the processor refuses it. Compiler Messages gives both and their fixes. A Map or other container of such records that the record leaves unwidened gets a note asking for the field to be renamed before it is widened. A field named like a method that takes arguments, such as get or count, navigates as usual. A navigator's form for a wider path follows the same rule. A navigator into a record with a field named headOption, which only a TraversalPath has, has no TraversalForm, so a hop into that record from a list returns the plain path. The record that declares the field reaching it gets a note naming the field to rename, if it writes generateNavigators = true.
Two fields with one navigator name
A navigator class takes its field's name with the first letter in upper case. So two fields whose names differ only in that letter's case, such as pouches and Pouches, would name one class, PouchesNavigator, where both would have a navigator. The first in declaration order keeps it. On a record that leaves navigators to the default, the later one keeps its plain path, with a note; on one that writes generateNavigators = true, the processor refuses it. Rename either field, or keep the later one a plain path, as the message says. An earlier field with no navigator leaves the name free: a String pouches, say, or one kept a plain path because the record it reaches has a field named like a path method. A message whose fix would end here asks first for one of the two to be renamed.
A target with type parameters
A navigator into a generic record declares that record's type parameters after its source type. The field supplies their type arguments as it declares them, so Revision<EmailAddress> email gets an EmailNavigator<ContactHistory, EmailAddress>:
// A value kept with the time it last changed, generic so that it can hold any of the cast's values
@GenerateFocus
record Revision<T>(T current, Instant changedAt) {}
@GenerateFocus
record ContactHistory(Revision<EmailAddress> email, Revision<?> lastEdit, Instant openedAt) {}
FocusPath<ContactHistory, EmailAddress> current = ContactHistoryFocus.email().current();
A type variable of the declaring record is passed on the same way, and so is an argument that carries a wildcard of its own, such as List<?>. An SPI container's element follows the same rule.
A field that names no single type for one of those parameters gets no navigator. That is a wildcard such as lastEdit's Revision<?> or Revision<? extends EmailAddress>, or a raw Revision. No chain through RevisionFocus reaches inside it either, because each of its paths starts from one instantiation of Revision. The processor says so in a note against the field. Where the parameter's bound is a type it can write, the note names a type to use instead:
@GenerateFocus
record Revision<T>(T current, Instant changedAt) {}
@GenerateFocus
record ContactHistory(Revision<EmailAddress> email, Revision<?> lastEdit, Instant openedAt) {}
Note: Navigator for field 'lastEdit' is not generated: a navigator into Revision has to name one type for each of its type parameters, which Revision<?> does not. ContactHistoryFocus.lastEdit() returns FocusPath<ContactHistory, Revision<?>> instead. A path from one instantiation of Revision does not compose with it, but a lens you write from Revision<?> does, through .via(). Write Revision<Object>, or another concrete instantiation, in place of Revision<?> to get a navigator.
The note names the path the field's method returns, which follows the field's path widening. That path focuses on the record, so a lens you write from Revision<?> composes with it, as A wildcard inside the element shows. If the focus may be null, as an element written @Nullable may, the note puts .some(Affines.nullable()) before the lens. A Map or an array the record does not widen keeps a path to the whole container, so it has no navigator to miss and draws no note about its wildcard. If the record's companion is also missing or out of reach, the offer says what else a navigator needs. The offer asks first for any field rename the navigator needs.
Where a @Nullable counts
The processor reads each recognised annotation wherever its own @Target puts it: JSpecify's TYPE_USE on the component's type, JetBrains', AndroidX's and SpotBugs' on the accessor, JSR-305's and Jakarta's on the component itself. A nullable container widens through .nullable(), then its own step if it has one: @Nullable List<T> is .nullable().each(EachInstances.listEach()), so a null list has no elements. Position counts as Java defines it, so String @Nullable [] is a nullable array, while @Nullable String[] and List<@Nullable String> annotate the elements.
Under a JSpecify checker such as NullAway, every optic and Focus path type takes a nullable focus. So a path typed FocusPath<LegacyContact, @Nullable String> checks, and its .nullable() is the non-null AffinePath<LegacyContact, String>. A read that hands the focus back in an Optional or a Maybe, such as getOptional, preview or toMaybePath, reads a null focus as absent, since neither can hold one. toEitherPath and toTryPath each have an overload that says what a null focus becomes, as A focus that may be null shows. To focus an Effect Path through such a path, pass path.nullable() to the Effect Path's focus, with the error that overload takes.
Path widening
Widening is what turns a lens to a field into the path type the field's shape deserves: a container that may hold nothing widens to an AffinePath, one that may hold many widens to a TraversalPath. It happens for every path, navigators or not, and it is settled at compile time from the declared type. The gap is the bare nullable reference nobody annotated: its declared type says String, so you get a FocusPath and chain .nullable() yourself.
SPI containers
Each SPI generator that names an optic declares a Cardinality, the number of values its container can hold, and that decides the path type:
| Cardinality | Path | Types |
|---|---|---|
ZERO_OR_ONE | AffinePath | Either<L,R>, Try<A>, Validated<E,A> |
ZERO_OR_MORE | TraversalPath, under widenCollections, or when widenFields names the field | Map<K,V>, arrays of a reference type, Eclipse Collections, Guava, Vavr, Apache Commons |
// Either is ZERO_OR_ONE via the SPI: AffinePath
AffinePath<Stockroom, String> verified = StockroomFocus.verifiedName();
// Map is ZERO_OR_MORE via the SPI, but a Focus method widens it only under
// widenCollections or widenFields; otherwise the path still focuses the whole map
FocusPath<Stockroom, Map<String, Integer>> stock = StockroomFocus.stock();
TraversalPath<Stockroom, Integer> quantities = stock.each(EachInstances.mapValuesEach());
ZERO_OR_MORE SPI types are the one asymmetry: a Focus method leaves them un-widened by default, for backwards compatibility. Add widenCollections = true to the annotation and StockroomFocus.stock() returns the TraversalPath directly, or widen that one field with widenFields = "stock". A navigator follows the same rule: it steps into a container only where the record widens it, so a navigator never changes what a field's method focuses on. Custom Containers and Code Generation states the rule in full, alongside the table of every supported container.
Compound widening
Composing paths keeps the wider path type:
| Current | + Field | = Result |
|---|---|---|
| FOCUS | AFFINE | AFFINE |
| FOCUS | TRAVERSAL | TRAVERSAL |
| AFFINE | AFFINE | AFFINE |
| AFFINE | TRAVERSAL | TRAVERSAL |
| TRAVERSAL | anything | TRAVERSAL |
If you write a TraversableGenerator for your own container type, override generateOpticExpression() with the optic that reads it, or the path stops at the container. Override getCardinality() to return ZERO_OR_ONE for optional-like types; the default ZERO_OR_MORE is correct for collection-like types. See Traversal Generator Plugins.
Nested container widening
A field whose type nests containers gets a composed chain, up to three levels deep. In the table, optionalSome() is FocusPaths.optionalSome() and listEach() is EachInstances.listEach():
| Field Type | Generated Chain | Return Type |
|---|---|---|
Optional<List<String>> | .some(optionalSome()).each(listEach()) | TraversalPath |
List<Optional<String>> | .each(listEach()).some(optionalSome()) | TraversalPath |
Optional<Optional<String>> | .some(optionalSome()).some(optionalSome()) | AffinePath |
List<List<String>> | .each(listEach()).each(listEach()) | TraversalPath |
Optional<Either<E, String>> | .some(optionalSome()).some(Affines.eitherRight()) | AffinePath |
Either<E, List<Integer>> | .some(Affines.eitherRight()).each(listEach()) | TraversalPath |
Either<E, Map<K, V>> | .some(Affines.eitherRight()) | AffinePath to the Map |
Either<E, Map<K, V>> with widenCollections = true | .some(Affines.eitherRight()).each(EachInstances.mapValuesEach()) | TraversalPath |
The last two rows are the rule in miniature. Optional, Maybe, List, Set and Collection nest unconditionally, but an inner container that arrives through the SPI is stepped into only when it is ZERO_OR_ONE, or ZERO_OR_MORE with widenCollections on. Otherwise the path stops at the container.
TraversalPath<NestedContainers, String> allTags = NestedContainersFocus.tags();
List<String> tagValues = allTags.getAll(containers);
AffinePath<NestedContainers, String> nestedOpt = NestedContainersFocus.nested();
Optional<String> innerValue = nestedOpt.getOptional(containers);
// Either<String, List<Integer>>: a nested List is stepped into unconditionally
TraversalPath<NestedContainers, Integer> data = NestedContainersFocus.data();
// Either<String, Map<String, Integer>>: an SPI ZERO_OR_MORE stops at the Map...
AffinePath<NestedContainers, Map<String, Integer>> meta = NestedContainersFocus.meta();
// ...unless widenCollections is on
TraversalPath<WidenedConfig, Integer> hits = WidenedConfigFocus.meta();
Beyond three levels, compose the rest with .via().
- A generated collection method is element-level; a generated
Mapmethod is not.FocusPaths.listAt(i)andListPrismsstart fromFocusPath.of(theLens()), because there is no generatedorder.line(0).FocusPaths.mapAt(k)composes straight onto the generated path, because that path still focuses the whole map. - An
Optional, or a recognised@Nullable, is already anAffinePath. Chain.nullable()yourself for a field nobody annotated, or one carrying Spring's@Nullable. - One variant of a sealed type is a prism away.
@GeneratePrismsnames each variant of a type you own;AffinePath.instanceOfmatches by runtime type in one you do not. - A navigator covers an annotated record, or a container of one. An
Optional,Maybe,List,Set,CollectionorEitherof one always gets a navigator, nested or not, and so does aKindthe processor traverses. AMapor an array gets one where the record widens it. - A navigator is a path. Every path method works on it, and it goes wherever a path of its type goes.
- Custom Containers and Code Generation: what the processor emits per field type, and the SPI
- Each: the
Eachinstances.each(Each)takes - List Decomposition:
ListPrismsin full
Previous: Focus DSL Next: What a Path Is Made Of