Focus DSL: Custom Containers and Code Generation

Predict which path type a container field generates, and teach the processor a container of your own.

What You'll Learn

  • Predict a field's path type from its container's cardinality: exactly one, zero or one, or zero or more
  • Decide whether to set widenCollections = true or name one field in widenFields, and say why a Map field stops at the container without them
  • Check whether a JDK, HKJ, Eclipse Collections, Guava, Vavr, Apache Commons or PCollections container works out of the box
  • Predict when @GenerateFocus rejects a raw or wildcard container type argument, and what a wildcard inside the element gives you
  • Register your own container type by implementing the TraversableGenerator SPI

The Focus DSL never asks you which path type you want. It reads the field's type and picks: exactly one, zero or one, or zero or more. This page is the rule book for that choice.


Generated Class Structure

Given a record with one field of each interesting shape:

@GenerateLenses
@GenerateFocus
record Candidate(
    String name,
    int age,
    Optional<String> email,
    List<Skill> skills,
    Either<String, Integer> timeout,
    Map<String, Integer> scores) {}

the processor generates one method per component, each already carrying the widening its type calls for:

FocusPath<Candidate, String> name = CandidateFocus.name();          // plain field
FocusPath<Candidate, Integer> age = CandidateFocus.age();           // boxed primitive

AffinePath<Candidate, String> email = CandidateFocus.email();       // .some(optionalSome())
TraversalPath<Candidate, Skill> skills = CandidateFocus.skills();   // .each(listEach())

// SPI ZERO_OR_ONE: .some(Affines.eitherRight())
AffinePath<Candidate, Integer> timeout = CandidateFocus.timeout();

// SPI ZERO_OR_MORE: not widened by default, so the path still focuses the Map
FocusPath<Candidate, Map<String, Integer>> scores = CandidateFocus.scores();
TraversalPath<Candidate, Integer> allScores = scores.each(EachInstances.mapValuesEach());

Two things are worth noticing. There is exactly one method per component, so indexed accessors such as skill(0) are not generated; index with .via(FocusPaths.listAt(0)) from FocusPath.of(CandidateLenses.skills()) instead. And the Map field is the odd one out, which the next section explains.


Cardinality: the Rule Behind the Choice

Every container holds its values in one of two ways. It either wraps at most one value (Either holds a success or a failure) or zero or more (a Map holds many entries). The Focus DSL calls this the container's cardinality, and it decides the generated path type:

flowchart TD
    accTitle: The path type a container's cardinality implies
    accDescr: A plain field holds exactly one value and implies a FocusPath. Optional, Maybe, Either, Try and Validated hold zero or one and imply an AffinePath. List, Set, Collection, Map, arrays and third-party collections hold zero or more and imply a TraversalPath.
    C{"How many values<br/>can the container hold?"}
    C -->|"exactly one<br/>(a plain field)"| F(["FocusPath"])
    C -->|"zero or one:<br/>Optional, Maybe,<br/>Either, Try, Validated"| A(["AffinePath"])
    C -->|"zero or more:<br/>List, Set, Collection,<br/>Map, arrays, third-party<br/>collections"| T@{ shape: st-rect, label: "TraversalPath" }

    classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
    classDef rw fill:#a6d189,stroke:#40a02b,color:#232634
    classDef maybe fill:#a6d189,stroke:#40a02b,color:#232634,stroke-dasharray:6 4
    class C decision
    class F,T rw
    class A maybe

In words: a plain field implies a FocusPath, a container of zero or one an AffinePath, and a container of zero or more a TraversalPath.

The diagram gives the tier a container's cardinality implies. Which of them a static Focus method actually widens to is the next section's subject: List, Set and Collection always do, and the rest wait for widenCollections or widenFields.

Nested containers compose the same rule up to three levels: Optional<List<String>> becomes .some(FocusPaths.optionalSome()).each(EachInstances.listEach()) and lands on a TraversalPath. See Nested Container Widening.

The ZERO_OR_MORE Asymmetry, and widenCollections

List, Set and Collection are recognised directly by the processor and widened at the static method. Every other ZERO_OR_MORE container arrives through the service-provider interface (SPI) (Map, arrays, and the third-party collections below) and is left un-widened by default, for backwards compatibility. One flag turns the widening on:

// Default: the static method stops at the container
FocusPath<Candidate, Map<String, Integer>> scores = CandidateFocus.scores();

// @GenerateFocus(widenCollections = true): the static method steps into it
TraversalPath<WidenedCandidate, Integer> widened = WidenedCandidateFocus.scores();

Why this matters

widenCollections = true is usually what you want on a new record: it makes Map fields behave exactly like List fields, and the same flag applies one level down inside a nested container. It is opt-in only because turning it on changes a method's return type, and that is a source-breaking change for anyone already calling it. New code has nothing to break.

To widen some containers and keep others whole, name the fields instead: @GenerateFocus(widenFields = "scores") steps into scores and leaves every other container as it is. A name that matches no component is an error. A name widenFields cannot change draws a warning: a List, which its method widens anyway, or a String, which holds no container. See Compiler Messages.

Navigators follow the same rule rather than adding to it. A container whose element is itself a @GenerateFocus record gets a navigator over its elements wherever its method steps into them. A List<Address> always does, so its method returns a navigator and the next hop, such as .city(), chains. Map<String, Address> stays a path to the whole map until widenCollections or widenFields says otherwise, and then its method returns a navigator over the values. Turning navigators on therefore never changes what a field's method focuses on.

Whichever way you reach a field, by the static method or by a navigator on a record that holds this one, it widens the same way. The setting that decides it belongs to the record that declares the component, not to the one navigating to it.


Supported Container Types

ZERO_OR_ONE containers, which produce an AffinePath:

ContainerSourceOptic used
Optional<A>JDKFocusPaths.optionalSome() (recognised directly)
Maybe<A>HKJAffines.just() (recognised directly)
Either<L, R>HKJAffines.eitherRight()
Try<A>HKJAffines.trySuccess()
Validated<E, A>HKJAffines.validatedValid()

Optional and Maybe are recognised by name rather than by the SPI, and they do not share an optic: an Optional field widens through .some(FocusPaths.optionalSome()), a Maybe field through .some(Affines.just()).

ZERO_OR_MORE containers, which produce a TraversalPath (at the static method for the first three rows, which are recognised by name, and for the rest only under widenCollections = true):

ContainerSourceOptic used
List<A>JDKEachInstances.listEach() (recognised directly)
Set<A>JDKEachInstances.setEach() (recognised directly)
Collection<A>JDKEachInstances.collectionEach() (recognised directly)
Map<K, V>JDKEachInstances.mapValuesEach()
A[], A a reference typeJDKEachInstances.arrayEach()
ImmutableList, MutableList, ImmutableSet, MutableSet, ImmutableBag, MutableBag, ImmutableSortedSet, MutableSortedSetEclipse CollectionsEachInstances.fromIterableCollecting(...)
ImmutableList, ImmutableSetGuavaEachInstances.fromIterableCollecting(...)
List, SetVavrEachInstances.fromIterableCollecting(...)
HashBag, UnmodifiableListApache CommonsEachInstances.fromIterableCollecting(...)
PVector, PStack, PSet, PSortedSet, PBagPCollectionsEachInstances.fromIterableCollecting(...)
PMap, PSortedMapPCollectionsEachInstances.mapValuesEachCollecting(...)

The three JDK collections are recognised by name rather than by the SPI, but they do not share one traversal: a List field is widened with EachInstances.listEach(), a Set field with EachInstances.setEach() and a Collection field with EachInstances.collectionEach(). Each rebuilds a value the component can take back, which is what lets the modified result go home:

// Each of these widened at the static method, through its own Each instance
TraversalPath<Vacancy, Skill> requiredSkills = VacancyFocus.skills();
TraversalPath<Vacancy, String> vacancyTags = VacancyFocus.tags();

// The path is a TraversalPath, so the Set widened at the static method
Vacancy raised =
    requiredSkills.via(SkillFocus.proficiency()).modifyAll(p -> p + 1, vacancy);

A Collection names no ordering and no uniqueness, so collectionEach() rebuilds whichever it was given. A set-valued Collection comes back a set, specifically a LinkedHashSet, so a TreeSet or other SortedSet source keeps its elements but not its ordering contract. Anything else comes back a List. A Collection that is neither, such as an ArrayDeque, also comes back a List; those types inherit identity equals, so no rebuild could return a collection equal to the one it was handed.

An array of a reference type widens through arrayEach(), which builds the new array with the component type the source array was created with. An array of a primitive type stays a plain FocusPath under every setting, since arrayEach() is Each<A[], A> and no primitive is an A:

// @GenerateFocus(widenCollections = true) steps into an array of a reference type
TraversalPath<WidenedCandidate, String> tags = WidenedCandidateFocus.tags();

// No Each focuses an int, so a primitive array stays in focus
FocusPath<WidenedCandidate, int[]> levels = WidenedCandidateFocus.levels();

// The whole array, to replace it or index into it, starts from its lens
FocusPath<WidenedCandidate, String[]> allTags = FocusPath.of(WidenedCandidateLenses.tags());

Because the new array keeps its source's component type, a write must store values that type can hold, as any Java array store must. A component declared Number[] that holds an Integer[] cannot take a Double: the path's type allows it, but the write throws ArrayStoreException. @GenerateTraversals builds a Number[] for the same field, so its traversal takes the Double.

Every third-party collection generator goes through EachInstances.fromIterableCollecting(collector), a generic factory that iterates the container, traverses the elements with the applicative, and rebuilds the container through the collector it is given; the map-shaped ones go through mapValuesEachCollecting instead. No extra HKJ module is needed: the library itself you supply, and a project that names one of these types in a record already has it on the classpath.

// Eclipse Collections, no manual wiring: the SPI generator supplies the Each instance
FocusPath<Allocation, ImmutableList<Holding>> holdings = AllocationFocus.holdings();

TraversalPath<Allocation, Holding> eachHolding =
    holdings.each(EachInstances.fromIterableCollecting(list -> Lists.immutable.ofAll(list)));

Allocation rebalanced =
    eachHolding
        .via(HoldingFocus.weight())
        .modifyAll(w -> w * 1.05, allocation);

Raw and wildcard container type arguments

An SPI container widens by naming the optic instance its generator supplies: .some(Affines.eitherRight()), .each(EachInstances.mapValuesEach()). Javac works out that instance's own type arguments from the field's type. A raw container gives it nothing to work from, and a wildcard stands for no one type, so there is nothing to name. Rather than write a call that cannot compile, @GenerateFocus rejects the component. The five built-in containers, Optional, Maybe, List, Set and Collection, are the exception: the processor names their instance for the element type and casts it to the type the field declares.

// Rejected: no Affine can be named for a wildcard type argument
@GenerateFocus
public record Holder(Either<String, ? extends Widget> boundedEither) {}
// Accepted
@GenerateFocus
public record Holder(Either<String, Widget> either) {}

Which fields the rule reaches follows from how each one widens:

FieldWidens byRaw or wildcard argument
Optional, Maybe, List, Set, CollectionFocusPaths.optionalSome(), Affines.just(), or the container's own Each from EachInstances, cast to the type the field declares✅ Accepted. List<? extends Widget> widens to TraversalPath<Holder, Widget>, and a raw Set to TraversalPath<Holder, Object>
Either, Try, Validated, Map, third-party collectionsnaming the optic its SPI generator supplies❌ Rejected, but only when the field is actually widened
Kind<W, A>the Traverse registered for the witness W✅ Accepted. A wildcard element resolves to its bound, so Kind<ListKind.Witness, ? extends Widget> widens to TraversalPath<Holder, Widget>; a wildcard witness names no Traverse at all, so Kind<?, Widget> stays a plain FocusPath (see Kind Field Support)
Any container in this table whose element is a generic type it does not step into, such as List<Box<? extends Widget>>the container's own widening, as for any other element✅ Accepted under every setting. The element is kept whole, so List<Box<? extends Widget>> widens to TraversalPath<Crate, Box<? extends Widget>> (see A wildcard inside the element)

Three things narrow the rule further.

Only the container's own type arguments count, focused or not. Either<?, Widget> is rejected even though the wildcard sits on the side the path never looks at. One level down is fine, because the container itself is still fully named: Either<String, List<? extends Widget>> widens to .some(Affines.eitherRight()).each(EachInstances.listEach()), with the Each cast to one over List<? extends Widget>. Once the walk steps into the List, its own arguments are the ones that count. An element it does not step into keeps any wildcard it carries, as A wildcard inside the element shows. An array has no type arguments of its own, so the array itself is never rejected, and the walk judges its element as any other. Under widenCollections = true, Set<? extends Widget>[] widens to TraversalPath<Holder, Widget>, while Either<?, Widget>[] is rejected.

A ZERO_OR_MORE SPI container is only rejected when something widens it, which means widenCollections = true, or widenFields naming the field. At the default settings the path stops at the container, no optic is named, and the wildcard costs nothing. That covers wildcards below it too: Map<String, Either<String, ? extends Widget>> compiles at the default settings, because the Map is never widened and so the Either inside it is never asked for an optic.

A generator that names no optic expression is exempt, because it widens nothing. The path stops at its container, as if no generator supported it, so a raw or wildcard argument is never asked for an optic. Every generator HKJ ships for a type the SPI widens names an optic, so this only applies to one of your own.

The rule follows the optic instance, so it is @GenerateFocus's alone. @GenerateTraversals reads the same component and writes a Traversal over the type the wildcard stands for. Nothing is inferred there, so nothing can fail. See Wildcard Element Types.

A wildcard inside the element

When the element is a generic type of your own, such as Box<? extends Widget>, the container widens as it would for any other element, under every setting. The path focuses on that whole type, wildcard and all:

@GenerateFocus
public record Box<T>(T value) {}

@GenerateFocus
public record Crate(List<Box<? extends Widget>> boxes) {}

// The wildcard stays inside the element
TraversalPath<Crate, Box<? extends Widget>> boxes = CrateFocus.boxes();

CrateFocus takes you as far as each Box. BoxFocus.value() is a path from Box<T> for one T. Java generics are invariant, so a Box<? extends Widget> is not a Box<T> for any one T. Naming the type, as in BoxFocus.<Widget>value(), gives a path from Box<Widget>, which does not fit either. Both calls fail with "no suitable method found for via":

// Rejected by javac: Box<? extends Widget> is not Box<T> for any one T
TraversalPath<Crate, Widget> widgets = CrateFocus.boxes().via(BoxFocus.value());
TraversalPath<Crate, Widget> named = CrateFocus.boxes().via(BoxFocus.<Widget>value());

To reach the Widget, write the lens over the element type as declared, wildcard included:

Lens<Box<? extends Widget>, Widget> value =
    Lens.of(Box::value, (box, widget) -> new Box<>(widget));

TraversalPath<Crate, Widget> widgets = CrateFocus.boxes().via(value);

The setter needs only the new value because Box has one component. With more, it copies the others from box, and LensLaws checks the result, as Every Write Runs the Canonical Constructor shows. If nothing needs the wildcard, declare List<Box<Widget>> instead: boxes() then returns a navigator, and boxes().value() chains.

Cross-Ecosystem Navigation

Real projects mix collection libraries: JDK collections for ordinary code, Eclipse Collections for high-performance immutable data, HKJ types for typed error handling. Focus navigates all of them from one annotation, composing chains that cross ecosystem boundaries without ceremony. For a full walkthrough on a financial portfolio model, see Portfolio Risk Analysis.


The fine print: registering your own container type

A library can teach the processor about its own container by implementing TraversableGenerator. The interface is small: say which types you handle, what cardinality they have, which type argument is the focus, what optic expression to emit, and (the one method with no default) how to emit modifyF. Leave the optic expression out and the generator serves @GenerateTraversals and @ImportOptics only: under @GenerateFocus, the path stops at the container, as if no generator supported it.

@ServiceProvider(TraversableGenerator.class)
public final class ResultGenerator extends BaseTraversableGenerator {

  @Override
  public boolean supports(TypeMirror type) {
    return type instanceof DeclaredType d && d.asElement().toString().equals("com.example.Result");
  }

  @Override
  public Cardinality getCardinality() {
    return Cardinality.ZERO_OR_ONE;   // Result holds zero or one success value
  }

  @Override
  public int getFocusTypeArgumentIndex() {
    return 1;                          // Result<E, A> focuses on A
  }

  @Override
  public String generateOpticExpression() {
    return "ResultAffines.success()";  // a Java expression returning an Affine
  }

  @Override
  public Set<String> getRequiredImports() {
    return Set.of("com.example.optics.ResultAffines");
  }

  @Override
  public CodeBlock generateModifyF(
      RecordComponentElement component,
      ClassName recordClassName,
      List<? extends RecordComponentElement> allComponents) {
    // The body of the generated Traversal.modifyF for this container.
    // EitherGenerator in hkj-processor-plugins is a worked implementation.
  }
}

generateModifyF is the only member without a default, so a generator that omits it will not compile: BaseTraversableGenerator supplies the generic-type and constructor-argument helpers, not this.

@ServiceProvider (from Avaje) writes the META-INF/services entry and checks the module-info.java provides clause for you. Once the generator is on the annotation processor path, any @GenerateFocus record with a Result<E, A> field generates an AffinePath that calls .some(ResultAffines.success()).

See Also

Traversal Generator Plugins is the full implementation guide: generator discovery, priority, the modifyF code generation hook, and the testing approach.


Key Takeaways

  • The field type picks the path type, through cardinality. Zero or one gives an AffinePath, List/Set/Collection a TraversalPath, anything else a FocusPath. Every other zero-or-more container stops at the container until widenCollections says otherwise.
  • Optional, Maybe, List, Set and Collection are built in. Everything else, including Map and arrays, arrives through the TraversableGenerator SPI.
  • widenCollections = true removes the asymmetry, and widenFields removes it for chosen fields. Without either, an SPI ZERO_OR_MORE container stops at the container, in static Focus methods and navigator methods alike, whatever its element.
  • Third-party collections need no extra HKJ module. One generic fromIterableCollecting factory covers Eclipse Collections, Guava, Vavr, Apache Commons and PCollections, on top of the ecosystem dependency you already declare to name the type.
  • The SPI is open. Implement supports, getCardinality, getFocusTypeArgumentIndex, generateOpticExpression and the one method with no default, generateModifyF; register with @ServiceProvider, and your container becomes a first-class Focus field.

Hands-On Learning

Practise container navigation in Tutorial 20: Custom Container Navigation (4 exercises).

See Also


Previous: Type Class and Effect Integration Next: Kind Field Support