Focus DSL: Custom Containers and Code Generation

What You'll Learn

  • What the annotation processor generates for each field type
  • How container cardinality (ZERO_OR_ONE vs ZERO_OR_MORE) determines the generated path type
  • The full table of supported container types across HKJ, JDK, Eclipse Collections, Guava, Vavr, and Apache Commons
  • How to register your own container types via the TraversableGenerator SPI

Hands On Practice

Tutorial20_ContainerNavigation.java (4 exercises, ~12 minutes)


Generated Class Structure

For a record like:

@GenerateLenses
@GenerateFocus
record Employee(
    String name,
    int age,
    Optional<String> email,
    @Nullable String nickname,
    List<Skill> skills
) {}

The processor generates:

@Generated
public final class EmployeeFocus {
    private EmployeeFocus() {}

    // Required fields -> FocusPath
    public static FocusPath<Employee, String> name() {
        return FocusPath.of(EmployeeLenses.name());
    }

    public static FocusPath<Employee, Integer> age() {
        return FocusPath.of(EmployeeLenses.age());
    }

    // Optional<T> field -> AffinePath (automatically unwraps with .some())
    public static AffinePath<Employee, String> email() {
        return FocusPath.of(EmployeeLenses.email()).some();
    }

    // @Nullable field -> AffinePath (automatically handles null with .nullable())
    public static AffinePath<Employee, String> nickname() {
        return FocusPath.of(EmployeeLenses.nickname()).nullable();
    }

    // List<T> field -> TraversalPath (traverses elements)
    public static TraversalPath<Employee, Skill> skills() {
        return FocusPath.of(EmployeeLenses.skills()).each();
    }

    // Indexed access to List<T> -> AffinePath
    public static AffinePath<Employee, Skill> skill(int index) {
        return FocusPath.of(EmployeeLenses.skills()).at(index);
    }

    // Either<String, Integer> field -> AffinePath (SPI widening with .some(Affine))
    public static AffinePath<Employee, Integer> timeout() {
        return FocusPath.of(EmployeeLenses.timeout()).some(Affines.eitherRight());
    }

    // Map<String, Integer> field -> TraversalPath (SPI widening with .each(Each))
    public static TraversalPath<Employee, Integer> scores() {
        return FocusPath.of(EmployeeLenses.scores()).each(EachInstances.mapValuesEach());
    }
}

Custom Container Types

The Focus DSL automatically recognises Optional, List, and Set fields. But what about Either, Try, Map, or your own container types?

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

  • Zero or one (e.g., Either<L, R>, Try<A>, Validated<E, A>) produces an AffinePath; the value may or may not be present.
  • Zero or more (e.g., Map<K, V>, T[]) produces a TraversalPath; there may be many values to iterate over.

The TraversableGenerator SPI lets any container type participate in this path widening. When @GenerateFocus encounters a registered container field, it generates the correct AffinePath or TraversalPath automatically, with no manual composition needed.

How It Works

For a record with an Either field:

@GenerateFocus
record ApiResponse(int status, Either<String, UserData> body) {}

The processor generates:

// body() returns AffinePath, not FocusPath, because Either is a ZERO_OR_ONE container
public static AffinePath<ApiResponse, UserData> body() {
    return FocusPath.of(Lens.of(ApiResponse::body, ...)).some(Affines.eitherRight());
}

The .some(Affines.eitherRight()) call composes an Affine that focuses on the Right value, widening the path from FocusPath to AffinePath. For ZERO_OR_MORE SPI types (like Map), the static Focus method returns FocusPath for backwards compatibility; users call .each(EachInstances.mapValuesEach()) manually to widen to TraversalPath. Navigator methods, however, widen automatically.

Supported Container Types

The tables below show both the navigator path (the return type when navigating through a navigator chain) and the static Focus method return type (the type returned by a top-level XxxFocus.field() call). These differ for ZERO_OR_MORE SPI types; see the note after the tables.

HKJ and JDK types

ContainerCardinalityNavigator pathStatic Focus methodOptic used
Either<L, R>Zero or oneAffinePathAffinePathAffines.eitherRight()
Try<A>Zero or oneAffinePathAffinePathAffines.trySuccess()
Validated<E, A>Zero or oneAffinePathAffinePathAffines.validatedValid()
Maybe<A>Zero or oneAffinePathAffinePathAffines.just()
Optional<A>Zero or oneAffinePathAffinePath.some() (built-in)
Map<K, V>Zero or moreTraversalPathFocusPath ¹EachInstances.mapValuesEach()
T[] (arrays)Zero or moreTraversalPathFocusPath ¹EachInstances.arrayEach()
List<A>Zero or moreTraversalPathTraversalPath.each() (built-in)
Set<A>Zero or moreTraversalPathTraversalPath.each() (built-in)

¹ SPI ZERO_OR_MORE types return FocusPath from static Focus methods for backwards compatibility. Call .each(eachInstance) to widen manually.

Eclipse Collections

ContainerCardinalityNavigator pathStatic Focus methodOptic used
ImmutableList<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> Lists.immutable.ofAll(list))
MutableList<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> Lists.mutable.ofAll(list))
ImmutableSet<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> Sets.immutable.ofAll(list))
MutableSet<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> Sets.mutable.ofAll(list))
ImmutableBag<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> Bags.immutable.ofAll(list))
MutableBag<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> Bags.mutable.ofAll(list))
ImmutableSortedSet<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> SortedSets.immutable.ofAll(list))
MutableSortedSet<A>Zero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> SortedSets.mutable.ofAll(list))

Guava, Vavr, and Apache Commons

ContainerLibraryCardinalityNavigator pathStatic Focus methodOptic used
ImmutableList<A>GuavaZero or moreTraversalPathFocusPath ¹fromIterableCollecting(ImmutableList::copyOf)
ImmutableSet<A>GuavaZero or moreTraversalPathFocusPath ¹fromIterableCollecting(ImmutableSet::copyOf)
io.vavr.collection.List<A>VavrZero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> List.ofAll(list))
io.vavr.collection.Set<A>VavrZero or moreTraversalPathFocusPath ¹fromIterableCollecting(list -> HashSet.ofAll(list))
HashBag<A>Apache CommonsZero or moreTraversalPathFocusPath ¹fromIterableCollecting(HashBag::new)
UnmodifiableList<A>Apache CommonsZero or moreTraversalPathFocusPath ¹fromIterableCollecting(UnmodifiableList::new)

All third-party generators use EachInstances.fromIterableCollecting(collector), a generic factory that iterates the container, traverses elements with the applicative functor, and reconstructs the container via the provided collector function. No additional modules are needed; the user's project already has the third-party library on the classpath since it declared the container type in its record.

ZERO_OR_MORE static method behaviour

For ZERO_OR_MORE SPI types (all collection-like containers above), the static Focus method returns FocusPath, not TraversalPath. This preserves backwards compatibility. To traverse manually, call .each(eachInstance):

// Static method returns FocusPath<AssetClass, ImmutableList<Position>>
var positions = AssetClassFocus.positions();

// Manually widen to TraversalPath
TraversalPath<AssetClass, Position> traversal = positions.each(
    EachInstances.fromIterableCollecting(list -> Lists.immutable.ofAll(list)));

Navigator generation handles ZERO_OR_MORE automatically; navigator methods for third-party collection fields return TraversalPath without manual widening.

Cross-Ecosystem Navigation

Real-world Java projects often mix collection libraries: JDK collections for standard code, Eclipse Collections for high-performance immutable data, HKJ types (Either, Try, Validated) for typed error handling. The Focus DSL navigates across all of these with a single annotation, composing navigator chains that cross ecosystem boundaries transparently.

For a detailed walkthrough of cross-ecosystem navigation with a financial portfolio domain model, see the Portfolio Risk Analysis example in the Examples Gallery.

Registering Your Own Container Types

Third-party libraries can register their own container types by implementing TraversableGenerator and registering it via META-INF/services.

Step 1: Implement the SPI interface

package com.example.optics;

import org.higherkindedj.optics.processing.spi.TraversableGenerator;
import java.util.Set;

public class ResultGenerator extends BaseTraversableGenerator {

    @Override
    public String supportedTypeName() {
        return "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 (index 1)
    }

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

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

    // ... implement remaining methods from BaseTraversableGenerator
}

Step 2: Register via META-INF/services

Create the file src/main/resources/META-INF/services/org.higherkindedj.optics.processing.spi.TraversableGenerator:

com.example.optics.ResultGenerator

Step 3: Module system configuration (if using JPMS)

module com.example.optics {
    requires org.higherkindedj.processor;
    provides org.higherkindedj.optics.processing.spi.TraversableGenerator
        with com.example.optics.ResultGenerator;
}

Once registered, any @GenerateFocus record with a Result<E, A> field will automatically generate an AffinePath that calls .some(ResultAffines.success()).

Hands-On Learning

Practice container type navigation in Tutorial 20: Custom Container Navigation (4 exercises, ~12 minutes).

See Also


Previous: Type Class and Effect Integration Next: Focus DSL Reference