Focus DSL: Path-Based Optic Syntax

Type-Safe Navigation Through Nested Data

What You'll Learn

  • How to navigate deeply nested data structures with type-safe paths
  • Using @GenerateFocus to generate path builders automatically
  • Fluent cross-type navigation with generated navigators (no .via() needed)
  • The difference between FocusPath, AffinePath, and TraversalPath
  • Collection navigation with .each(), .each(Each), .at(), .some(), .nullable(), and .traverseOver()
  • Seamless nullable field handling with @Nullable annotation detection
  • Type class integration: effectful operations, monoid aggregation, and Traverse support
  • Working with sum types using instanceOf() and conditional modification with modifyWhen()
  • Composing Focus paths with existing optics
  • Debugging paths with traced()
  • When to use Focus DSL vs manual lens composition

The Focus DSL provides a fluent, path-based syntax for working with optics. Instead of manually composing lenses, prisms, and traversals, you can navigate through your data structures using intuitive method chains that mirror the shape of your data.


The Problem: Verbose Manual Composition

When working with deeply nested data structures, manual optic composition becomes verbose:

// Manual composition - verbose and repetitive
Lens<Company, String> employeeNameLens =
    CompanyLenses.departments()
        .andThen(DepartmentLenses.employees())
        .andThen(EmployeeLenses.name());

// Must compose at each use site
String name = employeeNameLens.get(company);

With the Focus DSL, the same operation becomes:

// Focus DSL - fluent and intuitive
String name = CompanyFocus.departments().employees().name().get(company);

Think of Focus Paths Like...

  • File system paths: /company/departments/employees/name
  • JSON pointers: $.departments[*].employees[*].name
  • XPath expressions: //department/employee/name
  • IDE navigation: Click through nested fields with autocomplete

The key difference: Focus paths are fully type-safe, with compile-time checking at every step.


A Step-by-Step Walkthrough

Step 1: Annotate Your Records

Add @GenerateFocus alongside @GenerateLenses to generate path builders:

import org.higherkindedj.optics.annotations.GenerateLenses;
import org.higherkindedj.optics.annotations.GenerateFocus;

@GenerateLenses
@GenerateFocus
public record Company(String name, List<Department> departments) {}

@GenerateLenses
@GenerateFocus
public record Department(String name, List<Employee> employees) {}

@GenerateLenses
@GenerateFocus
public record Employee(String name, int age, Optional<String> email) {}

Step 2: Use Generated Focus Classes

The annotation processor generates companion Focus classes with path builders:

// Generated: CompanyFocus.java
// Navigate to company name
FocusPath<Company, String> namePath = CompanyFocus.name();
String companyName = namePath.get(company);

// Navigate through collections
TraversalPath<Company, Department> deptPath = CompanyFocus.departments();
List<Department> allDepts = deptPath.getAll(company);

// Navigate to specific index
AffinePath<Company, Department> firstDeptPath = CompanyFocus.department(0);
Optional<Department> firstDept = firstDeptPath.getOptional(company);

Step 3: Chain Navigation Methods

Focus paths support fluent chaining for deep navigation:

// Deep path through collections
TraversalPath<Company, String> allEmployeeNames =
    CompanyFocus.departments()     // TraversalPath<Company, Department>
        .employees()               // TraversalPath<Company, Employee>
        .name();                   // TraversalPath<Company, String>

// Get all employee names across all departments
List<String> names = allEmployeeNames.getAll(company);

// Modify all employee names
Company updated = allEmployeeNames.modifyAll(String::toUpperCase, company);

The Three Path Types

Focus DSL provides three path types, mirroring the optic hierarchy:

         FocusPath<S, A>
        (exactly one focus)
               |
        AffinePath<S, A>
      (zero or one focus)
               |
      TraversalPath<S, A>
      (zero or more focus)

FocusPath: Exactly One Element

FocusPath<S, A> wraps a Lens<S, A> and guarantees exactly one focused element:

// Always succeeds - the field always exists
FocusPath<Employee, String> namePath = EmployeeFocus.name();

String name = namePath.get(employee);           // Always returns a value
Employee updated = namePath.set("Bob", employee);  // Always succeeds
Employee modified = namePath.modify(String::toUpperCase, employee);

Key Operations:

MethodReturn TypeDescription
get(S)AExtract the focused value
set(A, S)SReplace the focused value
modify(Function<A,A>, S)STransform the focused value
toLens()Lens<S, A>Extract underlying optic

AffinePath: Zero or One Element

AffinePath<S, A> wraps an Affine<S, A> for optional access:

// May or may not have a value
AffinePath<Employee, String> emailPath = EmployeeFocus.email();

Optional<String> email = emailPath.getOptional(employee);  // May be empty
Employee updated = emailPath.set("new@email.com", employee);  // Always succeeds
Employee modified = emailPath.modify(String::toLowerCase, employee);  // No-op if absent

Key Operations:

MethodReturn TypeDescription
getOptional(S)Optional<A>Extract if present
set(A, S)SReplace (creates if structure allows)
modify(Function<A,A>, S)STransform if present
matches(S)booleanCheck if value exists
toAffine()Affine<S, A>Extract underlying optic

TraversalPath: Zero or More Elements

TraversalPath<S, A> wraps a Traversal<S, A> for collection access:

// Focuses on multiple elements
TraversalPath<Department, Employee> employeesPath = DepartmentFocus.employees();

List<Employee> all = employeesPath.getAll(department);    // All employees
Department updated = employeesPath.setAll(defaultEmployee, department);
Department modified = employeesPath.modifyAll(e -> promote(e), department);

Key Operations:

MethodReturn TypeDescription
getAll(S)List<A>Extract all focused values
setAll(A, S)SReplace all focused values
modifyAll(Function<A,A>, S)STransform all focused values
filter(Predicate<A>)TraversalPath<S, A>Filter focused elements
toTraversal()Traversal<S, A>Extract underlying optic

Collection Navigation

The Focus DSL provides intuitive methods for navigating collections:

.each() - Traverse All Elements

Converts a collection field to a traversal over its elements:

// List<Department> -> traversal over Department
TraversalPath<Company, Department> allDepts = CompanyFocus.departments();

// Equivalent to calling .each() on a FocusPath to List<T>

.each(Each) - Traverse with Custom Each Instance

For containers that aren't automatically recognised by .each(), provide an explicit Each instance:

import org.higherkindedj.optics.each.EachInstances;
import org.higherkindedj.optics.extensions.EachExtensions;

// Custom container with Each instance
record Container(Map<String, Value> values) {}

Lens<Container, Map<String, Value>> valuesLens =
    Lens.of(Container::values, (c, v) -> new Container(v));

// Use mapValuesEach() to traverse map values
TraversalPath<Container, Value> allValues =
    FocusPath.of(valuesLens).each(EachInstances.mapValuesEach());

// HKT types via EachExtensions
record Wrapper(Maybe<Config> config) {}

TraversalPath<Wrapper, Config> maybeConfig =
    FocusPath.of(configLens).each(EachExtensions.maybeEach());

This method is available on FocusPath, AffinePath, and TraversalPath, enabling fluent navigation through any container type with an Each instance.

See Also

For available Each instances and how to create custom ones, see Each Typeclass.

.at(index) - Access by Index

Focuses on a single element at a specific index:

// Focus on first department
AffinePath<Company, Department> firstDept = CompanyFocus.department(0);

// Focus on third employee in second department
AffinePath<Company, Employee> specificEmployee =
    CompanyFocus.department(1).employee(2);

// Returns empty if index out of bounds
Optional<Department> maybe = firstDept.getOptional(emptyCompany);

.atKey(key) - Access Map Values

For Map<K, V> fields, access values by key:

@GenerateLenses
@GenerateFocus
record Config(Map<String, Setting> settings) {}

// Focus on specific setting
AffinePath<Config, Setting> dbSetting = ConfigFocus.setting("database");

Optional<Setting> setting = dbSetting.getOptional(config);

.some() - Unwrap Optional

For Optional<T> fields, unwrap to the inner value:

// Email is Optional<String>
AffinePath<Employee, String> emailPath = EmployeeFocus.email();

// Internally uses .some() to unwrap the Optional
Optional<String> email = emailPath.getOptional(employee);

List Decomposition with .via()

For list decomposition patterns (cons/snoc), compose with ListPrisms using .via():

import org.higherkindedj.optics.util.ListPrisms;

// Focus on the first item in a container
TraversalPath<Container, Item> items = ContainerFocus.items();
AffinePath<Container, Item> firstItem = items.via(ListPrisms.head());
Optional<Item> first = firstItem.getOptional(container);

// Focus on the last element
AffinePath<Container, Item> lastItem = items.via(ListPrisms.last());

// Pattern match with cons (head, tail)
TraversalPath<Container, Pair<Item, List<Item>>> consPath = items.via(ListPrisms.cons());
consPath.preview(container).ifPresent(pair -> {
    Item head = pair.first();
    List<Item> tail = pair.second();
    // Process head and tail...
});

// Alternative: use headOption() for first element access
AffinePath<Container, Item> firstViaHeadOption = items.headOption();
ListPrisms MethodTypeDescription
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)

See Also

For comprehensive documentation on list decomposition patterns, including stack-safe operations for large lists, see List Decomposition.

.nullable() - Handle Null Values

For fields that may be null, use .nullable() to treat null as absent:

record LegacyUser(String name, @Nullable String nickname) {}

// If @Nullable is detected, the processor generates this automatically
// Otherwise, chain with .nullable() manually:
FocusPath<LegacyUser, String> rawPath = LegacyUserFocus.nickname();
AffinePath<LegacyUser, String> safePath = rawPath.nullable();

// Null is treated as absent (empty Optional)
LegacyUser user = new LegacyUser("Alice", null);
Optional<String> result = safePath.getOptional(user);  // Optional.empty()

// Non-null values work normally
LegacyUser withNick = new LegacyUser("Bob", "Bobby");
Optional<String> present = safePath.getOptional(withNick);  // Optional.of("Bobby")

Automatic @Nullable Detection

When a field is annotated with @Nullable (from JSpecify, JSR-305, JetBrains, etc.), the @GenerateFocus processor automatically generates an AffinePath with .nullable() applied. No manual chaining required.


Composition with Existing Optics

Focus paths compose seamlessly with existing optics using .via():

Path + Lens = Path (or Affine)

// Existing lens for a computed property
Lens<Employee, String> fullNameLens = Lens.of(
    e -> e.firstName() + " " + e.lastName(),
    (e, name) -> { /* setter logic */ }
);

// Compose Focus path with existing lens
FocusPath<Department, String> employeeFullName =
    DepartmentFocus.manager().via(fullNameLens);

Path + Prism = AffinePath

// Prism for sealed interface variant
Prism<Shape, Circle> circlePrism = ShapePrisms.circle();

// Compose to get AffinePath
AffinePath<Drawing, Circle> firstCircle =
    DrawingFocus.shape(0).via(circlePrism);

Path + Traversal = TraversalPath

// Custom traversal
Traversal<String, Character> charsTraversal = StringTraversals.chars();

// Compose for character-level access
TraversalPath<Employee, Character> nameChars =
    EmployeeFocus.name().via(charsTraversal);

Fluent Navigation with Generated Navigators

Zero-Boilerplate Cross-Type Navigation

When navigating across multiple record types, the standard approach requires explicit .via() calls at each boundary. Generated navigators eliminate this boilerplate, enabling chains like CompanyFocus.headquarters().city() directly.

The Problem: Explicit Composition

Without navigators, cross-type navigation requires .via() at each type boundary:

// Without navigators - explicit .via() at each step
String city = CompanyFocus.headquarters()
    .via(AddressFocus.city().toLens())
    .get(company);

// Deep navigation becomes verbose
String managerCity = CompanyFocus.departments()
    .each()
    .via(DepartmentFocus.manager().toLens())
    .via(PersonFocus.address().toLens())
    .via(AddressFocus.city().toLens())
    .get(company);

The Solution: Generated Navigators

Enable navigator generation with generateNavigators = true:

@GenerateFocus(generateNavigators = true)
record Company(String name, Address headquarters) {}

@GenerateFocus(generateNavigators = true)
record Address(String street, String city) {}

// With navigators - fluent chaining
String city = CompanyFocus.headquarters().city().get(company);

// Navigators chain naturally
Company updated = CompanyFocus.headquarters().city()
    .modify(String::toUpperCase, company);

How Navigators Work

When generateNavigators = true, the processor generates navigator wrapper classes for fields whose types are also annotated with @GenerateFocus. The generated code looks like:

// Generated in CompanyFocus.java
public static HeadquartersNavigator<Company> headquarters() {
    return new HeadquartersNavigator<>(
        FocusPath.of(Lens.of(Company::headquarters, ...))
    );
}

// Generated inner class
public static final class HeadquartersNavigator<S> {
    private final FocusPath<S, Address> delegate;

    // Delegate methods - same as FocusPath
    public Address get(S source) { return delegate.get(source); }
    public S set(Address value, S source) { return delegate.set(value, source); }
    public S modify(Function<Address, Address> f, S source) { ... }

    // Navigation methods for Address fields
    public FocusPath<S, String> street() {
        return delegate.via(AddressFocus.street().toLens());
    }

    public FocusPath<S, String> city() {
        return delegate.via(AddressFocus.city().toLens());
    }

    // Access underlying path
    public FocusPath<S, Address> toPath() { return delegate; }
}

Path Type Widening

Navigators automatically widen path types when navigating through optional or collection fields:

Source Field TypeNavigator Returns
Regular field (Address address)FocusPath methods
Optional field (Optional<Address>)AffinePath methods
Collection field (List<Address>)TraversalPath methods
@GenerateFocus(generateNavigators = true)
record User(String name, Optional<Address> homeAddress, List<Address> workAddresses) {}

// homeAddress navigator methods return AffinePath
Optional<String> homeCity = UserFocus.homeAddress().city().getOptional(user);

// workAddresses navigator methods return TraversalPath
List<String> workCities = UserFocus.workAddresses().city().getAll(user);

Controlling Navigator Generation

Depth Limiting

Prevent excessive code generation for deeply nested structures:

@GenerateFocus(generateNavigators = true, maxNavigatorDepth = 2)
record Root(Level1 child) {}

// Depth 1: child() returns Level1Navigator
// Depth 2: child().nested() returns FocusPath (not a navigator)
// Beyond depth 2: use .via() for further navigation

FocusPath<Root, String> deepPath = RootFocus.child().nested()
    .via(Level3Focus.value().toLens());

Field Filtering

Include only specific fields in navigator generation:

@GenerateFocus(generateNavigators = true, includeFields = {"primary"})
record MultiAddress(Address primary, Address secondary, Address backup) {}

// primary() returns navigator with navigation methods
// secondary() and backup() return standard FocusPath<MultiAddress, Address>

Or exclude specific fields:

@GenerateFocus(generateNavigators = true, excludeFields = {"internal"})
record Config(Settings user, Settings internal) {}

// user() returns navigator
// internal() returns standard FocusPath (no nested navigation)

When to Use Navigators

Enable navigators when:

  • Navigating across multiple record types frequently
  • Deep navigation is common in your codebase
  • You want IDE autocomplete for nested fields
  • Teaching or onboarding developers

Keep navigators disabled when:

  • Fields reference third-party types (not annotated with @GenerateFocus)
  • You need minimal generated code footprint
  • The project has shallow data structures

Combining Navigators with Other Features

Navigators work seamlessly with all Focus DSL features:

// With type class operations
Company validated = CompanyFocus.headquarters().city()
    .modifyF(this::validateCity, company, EitherMonad.INSTANCE);

// With conditional modification
Company updated = CompanyFocus.departments()
    .each()
    .modifyWhen(d -> d.name().equals("Engineering"), this::promote, company);

// With tracing for debugging
FocusPath<Company, String> traced = CompanyFocus.headquarters().city()
    .traced((company, city) -> System.out.println("City: " + city));

Type Class Integration

The Focus DSL integrates deeply with higher-kinded-j type classes, enabling effectful operations, monoid-based aggregation, and generic collection traversal.

Effectful Modification with modifyF()

All path types support modifyF() for effectful transformations:

// Validation - accumulate all errors
Kind<ValidatedKind.Witness<List<String>>, User> result = userPath.modifyF(
    email -> EmailValidator.validate(email),
    user,
    ValidatedApplicative.instance()
);

// Async updates with CompletableFuture
Kind<CompletableFutureKind.Witness, Config> asyncResult = configPath.modifyF(
    key -> fetchNewApiKey(key),  // Returns CompletableFuture
    config,
    CompletableFutureApplicative.INSTANCE
);

// IO operations
Kind<IOKind.Witness, User> ioResult = userPath.modifyF(
    name -> IO.of(() -> readFromDatabase(name)),
    user,
    IOMonad.INSTANCE
);

Monoid-Based Aggregation with foldMap()

TraversalPath supports foldMap() for aggregating values:

// Sum all salaries using integer addition monoid
int totalSalary = employeesPath.foldMap(
    Monoids.integerAddition(),
    Employee::salary,
    company
);

// Concatenate all names
String allNames = employeesPath.foldMap(
    Monoids.string(),
    Employee::name,
    company
);

// Custom monoid for set union
Set<String> allSkills = employeesPath.foldMap(
    Monoids.set(),
    e -> e.skills(),
    company
);

Generic Collection Traversal with traverseOver()

When working with collections wrapped in Kind<F, A>, use traverseOver() with a Traverse instance:

// Given a field with Kind<ListKind.Witness, Role> type
FocusPath<User, Kind<ListKind.Witness, Role>> rolesPath = UserFocus.roles();

// Traverse into the collection
TraversalPath<User, Role> allRoles =
    rolesPath.<ListKind.Witness, Role>traverseOver(ListTraverse.INSTANCE);

// Now operate on individual roles
List<Role> roles = allRoles.getAll(user);
User updated = allRoles.modifyAll(Role::promote, user);

When to use traverseOver() vs each():

MethodUse Case
each()Standard List<T> or Set<T> fields
traverseOver()Kind<F, T> fields with custom Traverse
// For List<T> - use each()
TraversalPath<Team, User> members = TeamFocus.membersList().each();

// For Kind<ListKind.Witness, T> - use traverseOver()
TraversalPath<Team, User> members = TeamFocus.membersKind()
    .<ListKind.Witness, User>traverseOver(ListTraverse.INSTANCE);

Conditional Modification with modifyWhen()

Modify only elements that match a predicate:

// Give raises only to senior employees
Company updated = CompanyFocus.employees()
    .modifyWhen(
        e -> e.yearsOfService() > 5,
        e -> e.withSalary(e.salary().multiply(1.10)),
        company
    );

// Enable only premium features
Config updated = ConfigFocus.features()
    .modifyWhen(
        f -> f.tier() == Tier.PREMIUM,
        Feature::enable,
        config
    );

Working with Sum Types using instanceOf()

Focus on specific variants of sealed interfaces:

sealed interface Shape permits Circle, Rectangle, Triangle {}

// Focus on circles only
AffinePath<Shape, Circle> circlePath = AffinePath.instanceOf(Circle.class);

// Compose with other paths
TraversalPath<Drawing, Double> circleRadii =
    DrawingFocus.shapes()
        .via(AffinePath.instanceOf(Circle.class))
        .via(CircleFocus.radius());

// Modify only circles
Drawing updated = DrawingFocus.shapes()
    .via(AffinePath.instanceOf(Circle.class))
    .modifyAll(c -> c.withRadius(c.radius() * 2), drawing);

Path Debugging with traced()

Debug complex path navigation by observing values:

// Add tracing to see what values are accessed
FocusPath<Company, String> debugPath = CompanyFocus.ceo().name()
    .traced((company, name) ->
        System.out.println("Accessing CEO name: " + name + " from " + company.name()));

// Every get() call now logs the accessed value
String name = debugPath.get(company);

// For TraversalPath, observe all values
TraversalPath<Company, Employee> tracedEmployees = CompanyFocus.employees()
    .traced((company, employees) ->
        System.out.println("Found " + employees.size() + " employees"));

Bridging to Effect Paths

Focus paths and Effect paths share the same via composition operator but navigate different domains. The bridge API enables seamless transitions between them.

                    FOCUS-EFFECT BRIDGE

    ┌─────────────────────────────────────────────────────┐
    │                   Optics Domain                      │
    │  FocusPath<S, A> ──────────────────────────────────  │
    │  AffinePath<S, A> ──────────────────────────────────│
    │  TraversalPath<S, A> ───────────────────────────────│
    └──────────────────────────┬──────────────────────────┘
                               │
                               │ toMaybePath(source)
                               │ toEitherPath(source, error)
                               │ toTryPath(source, supplier)
                               ▼
    ┌─────────────────────────────────────────────────────┐
    │                   Effects Domain                     │
    │  MaybePath<A> ──────────────────────────────────────│
    │  EitherPath<E, A> ──────────────────────────────────│
    │  TryPath<A> ────────────────────────────────────────│
    │  IOPath<A> ─────────────────────────────────────────│
    │  ValidationPath<E, A> ──────────────────────────────│
    └──────────────────────────┬──────────────────────────┘
                               │
                               │ focus(FocusPath)
                               │ focus(AffinePath, error)
                               ▼
    ┌─────────────────────────────────────────────────────┐
    │           Back to Optics (within effect)             │
    │  EffectPath<B> ─────────────────────────────────────│
    └─────────────────────────────────────────────────────┘

Direction 1: FocusPath → EffectPath

Extract a value using optics and wrap it in an effect for further processing:

// FocusPath always has a value, so these always succeed
FocusPath<User, String> namePath = UserFocus.name();
MaybePath<String> maybeName = namePath.toMaybePath(user);          // → Just(name)
EitherPath<E, String> eitherName = namePath.toEitherPath(user);    // → Right(name)
TryPath<String> tryName = namePath.toTryPath(user);                // → Success(name)

// AffinePath may not have a value
AffinePath<User, String> emailPath = UserFocus.email();  // Optional<String> → String
MaybePath<String> maybeEmail = emailPath.toMaybePath(user);        // → Just or Nothing
EitherPath<String, String> eitherEmail =
    emailPath.toEitherPath(user, "Email not configured");          // → Right or Left

Bridge Methods on FocusPath:

MethodReturn TypeDescription
toMaybePath(S)MaybePath<A>Always Just(value)
toEitherPath(S)EitherPath<E, A>Always Right(value)
toTryPath(S)TryPath<A>Always Success(value)
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
toTryPath(S, Supplier<Throwable>)TryPath<A>Success or Failure
toOptionalPath(S)OptionalPath<A>Wraps in Java Optional effect

Bridge Methods on TraversalPath:

MethodReturn TypeDescription
toListPath(S)ListPath<A>All focused values as list
toStreamPath(S)StreamPath<A>Lazy stream of values
toMaybePath(S)MaybePath<A>First value if any

Direction 2: EffectPath.focus()

Apply structural navigation inside an effect context:

// Start with an effect containing a complex structure
EitherPath<Error, User> userPath = Path.right(user);

// Navigate within the effect using optics
EitherPath<Error, String> namePath = userPath.focus(UserFocus.name());

// AffinePath requires an error for the absent case
EitherPath<Error, String> emailPath =
    userPath.focus(UserFocus.email(), new Error("Email required"));

focus() Method Signatures:

Effect TypeFocusPath SignatureAffinePath Signature
MaybePath<A>focus(FocusPath<A, B>)MaybePath<B>focus(AffinePath<A, B>)MaybePath<B>
EitherPath<E, A>focus(FocusPath<A, B>)EitherPath<E, B>focus(AffinePath<A, B>, E)EitherPath<E, B>
TryPath<A>focus(FocusPath<A, B>)TryPath<B>focus(AffinePath<A, B>, Supplier<Throwable>)TryPath<B>
IOPath<A>focus(FocusPath<A, B>)IOPath<B>focus(AffinePath<A, B>, Supplier<RuntimeException>)IOPath<B>
ValidationPath<E, A>focus(FocusPath<A, B>)ValidationPath<E, B>focus(AffinePath<A, B>, E)ValidationPath<E, B>
IdPath<A>focus(FocusPath<A, B>)IdPath<B>focus(AffinePath<A, B>)MaybePath<B>

When to Use Each Direction

Use FocusPath → EffectPath when:

  • You have data and want to start an effect pipeline
  • Extracting values that need validation or async processing
  • Converting optic results into monadic workflows
// Extract and validate
EitherPath<ValidationError, String> validated =
    UserFocus.email()
        .toEitherPath(user, new ValidationError("Email required"))
        .via(email -> validateEmailFormat(email));

Use EffectPath.focus() when:

  • You're already in an effect context (e.g., after a service call)
  • Drilling down into effect results
  • Building validation pipelines that extract and check nested fields
// Service returns effect, then navigate
EitherPath<Error, Order> orderResult = orderService.findById(orderId);
EitherPath<Error, String> customerName =
    orderResult
        .focus(OrderFocus.customer())
        .focus(CustomerFocus.name());

Practical Example: Validation Pipeline

Combining both directions for a complete validation workflow:

// Domain model
record RegistrationForm(String username, Optional<String> email, Address address) {}
record Address(String street, Optional<String> postcode) {}

// Validation using Focus-Effect bridge
EitherPath<List<String>, RegistrationForm> validateForm(RegistrationForm form) {
    var formPath = Path.<List<String>, RegistrationForm>right(form);

    // Validate username (always present)
    var usernameValid = formPath
        .focus(FormFocus.username())
        .via(name -> name.length() >= 3
            ? Path.right(name)
            : Path.left(List.of("Username too short")));

    // Validate email if present
    var emailValid = formPath
        .focus(FormFocus.email(), List.of("Email required for notifications"))
        .via(email -> email.contains("@")
            ? Path.right(email)
            : Path.left(List.of("Invalid email format")));

    // Combine validations
    return usernameValid.via(u -> emailValid.map(e -> form));
}

See Also


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);
    }
}

Integration with Free Monad DSL

Focus paths integrate with OpticPrograms for complex workflows:

// Build a program using Focus paths
Free<OpticOpKind.Witness, Company> program = OpticPrograms
    .get(company, CompanyFocus.name().toLens())
    .flatMap(name -> {
        if (name.startsWith("Acme")) {
            return OpticPrograms.modifyAll(
                company,
                CompanyFocus.departments().employees().age().toTraversal(),
                age -> age + 1
            );
        } else {
            return OpticPrograms.pure(company);
        }
    });

// Execute with interpreter
Company result = OpticInterpreters.direct().run(program);

When to Use Focus DSL vs Manual Composition

Use Focus DSL When:

  • Navigating deeply nested structures with many levels
  • IDE autocomplete is important for discoverability
  • Teaching or onboarding developers new to optics
  • Prototyping before optimising for performance
// Focus DSL - clear intent, discoverable
List<String> emails = CompanyFocus
    .departments()
    .employees()
    .email()
    .getAll(company);

Use Manual Composition When:

  • Custom optics (computed properties, validated updates)
  • Performance-critical code (avoid intermediate allocations)
  • Reusable optic libraries (compose once, use everywhere)
  • Complex conditional logic in the optic itself
// Manual composition - more control, reusable
public static final Lens<Company, String> CEO_NAME =
    CompanyLenses.ceo()
        .andThen(ExecutiveLenses.person())
        .andThen(PersonLenses.fullName());

Use Focus DSL for navigation, then extract for reuse:

// Use Focus for exploration
var path = CompanyFocus.departments().employees().email();

// Extract and store the composed optic
public static final Traversal<Company, String> ALL_EMAILS =
    path.toTraversal();

// Reuse the extracted optic
List<String> emails = Traversals.getAll(ALL_EMAILS, company);

Common Patterns

Pattern 1: Batch Updates

// Give all employees in Engineering a raise
Company updated = CompanyFocus
    .departments()
    .filter(d -> d.name().equals("Engineering"))
    .employees()
    .salary()
    .modifyAll(s -> s.multiply(new BigDecimal("1.10")), company);

Pattern 2: Safe Deep Access

// Safely access deeply nested optional
Optional<String> managerEmail = CompanyFocus
    .department(0)
    .manager()
    .email()
    .getOptional(company);

// Handle absence gracefully
String email = managerEmail.orElse("no-manager@company.com");

Pattern 3: Validation with Focus

// Validate all employee ages
Validated<List<String>, Company> result = OpticOps.modifyAllValidated(
    company,
    CompanyFocus.departments().employees().age().toTraversal(),
    age -> age >= 18 && age <= 100
        ? Validated.valid(age)
        : Validated.invalid("Invalid age: " + age)
);

Performance Considerations

Focus paths add a thin abstraction layer over raw optics:

  • Path creation: Minimal overhead (simple wrapper objects)
  • Traversal: Identical to underlying optic performance
  • Memory: One additional object per path segment

Best Practice: For hot paths, extract the underlying optic:

// Cold path - Focus DSL is fine
var result = CompanyFocus.departments().name().getAll(company);

// Hot path - extract and cache the optic
private static final Traversal<Company, String> DEPT_NAMES =
    CompanyFocus.departments().name().toTraversal();

for (Company c : manyCompanies) {
    var names = Traversals.getAll(DEPT_NAMES, c);  // Faster
}

Customising Generated Code

Target Package

@GenerateFocus(targetPackage = "com.myapp.optics.focus")
record User(String name) {}
// Generates: com.myapp.optics.focus.UserFocus

Enable fluent cross-type navigation with generated navigator classes:

@GenerateFocus(generateNavigators = true)
record Company(String name, Address headquarters) {}

@GenerateFocus(generateNavigators = true)
record Address(String street, String city) {}

// Now navigate fluently without .via():
String city = CompanyFocus.headquarters().city().get(company);

Control how deep navigator generation goes with maxNavigatorDepth:

@GenerateFocus(generateNavigators = true, maxNavigatorDepth = 2)
record Organisation(Division division) {}

// Depth 1: Returns DivisionNavigator
// Depth 2: Returns FocusPath (not a navigator)
// Beyond: Use .via() for further navigation

Field Filtering

Control which fields get navigator generation:

// Only generate navigators for specific fields
@GenerateFocus(generateNavigators = true, includeFields = {"homeAddress"})
record Person(String name, Address homeAddress, Address workAddress) {}

// Or exclude specific fields
@GenerateFocus(generateNavigators = true, excludeFields = {"backup"})
record Config(Settings main, Settings backup) {}

Lens Fallback for Non-Annotated Types

When navigating to a type without @GenerateFocus, you can continue with .via():

// ThirdPartyRecord doesn't have @GenerateFocus
@GenerateLenses
@GenerateFocus
record MyRecord(ThirdPartyRecord external) {}

// Navigate as far as Focus allows, then use .via() with existing lens
FocusPath<MyRecord, ThirdPartyRecord> externalPath = MyRecordFocus.external();
FocusPath<MyRecord, String> deepPath = externalPath.via(ThirdPartyLenses.someField());

Common Pitfalls

Don't: Recreate paths in loops

// Bad - creates new path objects each iteration
for (Company c : companies) {
    var names = CompanyFocus.departments().name().getAll(c);
}

Do: Extract and reuse

// Good - create path once
var deptNames = CompanyFocus.departments().name();
for (Company c : companies) {
    var names = deptNames.getAll(c);
}

Don't: Ignore the path type

// Confusing - what does this return?
var result = somePath.get(source);  // Might fail if AffinePath!

Do: Use the appropriate method

// Clear - FocusPath always has a value
String name = namePath.get(employee);

// Clear - AffinePath might be empty
Optional<String> email = emailPath.getOptional(employee);

// Clear - TraversalPath has multiple values
List<String> names = namesPath.getAll(department);

Troubleshooting and FAQ

Compilation Errors

"Cannot infer type arguments for traverseOver"

Problem:

// This fails to compile
TraversalPath<User, Role> allRoles = rolesPath.traverseOver(ListTraverse.INSTANCE);

Solution: Provide explicit type parameters:

// Add explicit type witnesses
TraversalPath<User, Role> allRoles =
    rolesPath.<ListKind.Witness, Role>traverseOver(ListTraverse.INSTANCE);

Java's type inference struggles with higher-kinded types. Explicit type parameters help the compiler.

"Incompatible types when chaining .each().via()"

Problem:

// Type inference fails on long chains
TraversalPath<Company, Integer> salaries =
    FocusPath.of(companyDeptLens).each().via(deptEmployeesLens).each().via(salaryLens);

Solution: Break the chain into intermediate variables:

// Use intermediate variables
TraversalPath<Company, Department> depts = FocusPath.of(companyDeptLens).each();
TraversalPath<Company, Employee> employees = depts.via(deptEmployeesLens).each();
TraversalPath<Company, Integer> salaries = employees.via(salaryLens);

"Sealed or non-sealed local classes are not allowed"

Problem: Defining sealed interfaces inside test methods fails:

@Test void myTest() {
    sealed interface Wrapper permits A, B {}  // Compilation error!
    record A() implements Wrapper {}
}

Solution: Move sealed interfaces to class level:

class MyTest {
    sealed interface Wrapper permits A, B {}
    record A() implements Wrapper {}

    @Test void myTest() {
        // Use Wrapper here
    }
}

"Method reference ::new doesn't work with single-field records as BiFunction"

Problem:

// This fails for single-field records
Lens<Outer, Inner> lens = Lens.of(Outer::inner, Outer::new);  // Error!

Solution: Use explicit lambda:

Lens<Outer, Inner> lens = Lens.of(Outer::inner, (o, i) -> new Outer(i));

Runtime Issues

"getAll() returns empty unexpectedly"

Checklist:

  1. Check if the AffinePath in the chain has focus (use matches() to verify)
  2. Verify instanceOf() matches the actual runtime type
  3. Ensure the source data actually contains elements
// Debug with traced()
TraversalPath<User, Role> traced = rolesPath.traced(
    (user, roles) -> System.out.println("Found " + roles.size() + " roles")
);
List<Role> roles = traced.getAll(user);

"modifyAll() doesn't change anything"

Causes:

  • The traversal has no focus (AffinePath didn't match)
  • The predicate in modifyWhen() never matches
  • The source collection is empty
// Check focus exists
int count = path.count(source);
System.out.println("Path focuses on " + count + " elements");

FAQ

Q: When should I use each() vs traverseOver()?

ScenarioUse
Field is List<T>each()
Field is Set<T>each()
Field is Kind<ListKind.Witness, T>traverseOver(ListTraverse.INSTANCE)
Field is Kind<MaybeKind.Witness, T>traverseOver(MaybeTraverse.INSTANCE)
Custom traversable typetraverseOver(YourTraverse.INSTANCE)

Q: Why use MaybeMonad.INSTANCE for modifyF() instead of a dedicated Applicative?

MaybeMonad extends Applicative, so it works for modifyF(). Higher-Kinded-J doesn't provide a separate MaybeApplicative because:

  • Monad already provides all Applicative operations
  • Having one instance simplifies the API
  • Most effects you'll use with modifyF() are monadic anyway
// Use MaybeMonad for Maybe-based validation
Kind<MaybeKind.Witness, Config> result =
    keyPath.modifyF(validateKey, config, MaybeMonad.INSTANCE);

Q: Can I use Focus DSL with third-party types?

Yes, use .via() to compose with manually created optics:

// Create lens for third-party type
Lens<ThirdPartyType, String> fieldLens = Lens.of(
    ThirdPartyType::getField,
    (obj, value) -> obj.toBuilder().field(value).build()
);

// Compose with Focus path
FocusPath<MyRecord, String> path = MyRecordFocus.external().via(fieldLens);

Q: How do I handle nullable fields?

The Focus DSL provides four approaches for handling nullable fields, from most to least automated:

Option 1: Use @Nullable annotation (Recommended)

Annotate nullable fields with @Nullable from JSpecify, JSR-305, or similar. The processor automatically generates AffinePath with null-safe access:

import org.jspecify.annotations.Nullable;

@GenerateFocus
record User(String name, @Nullable String nickname) {}

// Generated: AffinePath that handles null automatically
AffinePath<User, String> nicknamePath = UserFocus.nickname();

User user = new User("Alice", null);
Optional<String> result = nicknamePath.getOptional(user);  // Optional.empty()

User withNick = new User("Bob", "Bobby");
Optional<String> present = nicknamePath.getOptional(withNick);  // Optional.of("Bobby")

Supported nullable annotations:

  • org.jspecify.annotations.Nullable
  • javax.annotation.Nullable
  • jakarta.annotation.Nullable
  • org.jetbrains.annotations.Nullable
  • androidx.annotation.Nullable
  • edu.umd.cs.findbugs.annotations.Nullable

Option 2: Use .nullable() method

For existing FocusPath instances, chain with .nullable() to handle nulls:

// If you have a FocusPath to a nullable field
FocusPath<LegacyUser, String> rawPath = LegacyUserFocus.nickname();

// Chain with nullable() for null-safe access
AffinePath<LegacyUser, String> safePath = rawPath.nullable();

Optional<String> result = safePath.getOptional(user);  // Empty if null

Option 3: Use AffinePath.ofNullable() factory

For manual creation without code generation:

// Create a nullable-aware AffinePath directly
AffinePath<User, String> nicknamePath = AffinePath.ofNullable(
    User::nickname,
    (user, nickname) -> new User(user.name(), nickname)
);

Option 4: Wrap in Optional (Alternative design)

If you control the data model, consider using Optional<T> instead of nullable fields:

// Model absence explicitly with Optional
record User(String name, Optional<String> email) {}

// Focus DSL handles it naturally with .some()
AffinePath<User, String> emailPath = UserFocus.email();  // Uses .some() internally

Q: What's the performance overhead of Focus DSL?

  • Path creation: Negligible (thin wrapper objects)
  • Operations: Same as underlying optics
  • Hot paths: Extract and cache the optic
// For performance-critical code, cache the extracted optic
private static final Traversal<Company, String> EMPLOYEE_NAMES =
    CompanyFocus.departments().employees().name().toTraversal();

// Use the cached optic in hot loops
for (Company c : companies) {
    List<String> names = Traversals.getAll(EMPLOYEE_NAMES, c);
}

Q: Can I create Focus paths programmatically (at runtime)?

Focus paths are designed for compile-time type safety. For runtime-dynamic paths, use the underlying optics directly:

// Build optics dynamically
Traversal<JsonNode, String> dynamicPath = buildTraversalFromJsonPath(jsonPathString);

// Wrap in a path if needed
TraversalPath<JsonNode, String> path = TraversalPath.of(dynamicPath);

Further Reading

  • Monocle: Focus DSL - Scala's equivalent, inspiration for this design

See Also

Hands-On Learning

Practice the Focus DSL in Tutorial 12: Focus DSL (10 exercises, ~12 minutes) and Tutorial 13: Advanced Focus DSL (8 exercises, ~12 minutes).


Previous: Introduction Next: Kind Field Support