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(), .some(Affine), .nullable(), and .traverseOver()
  • Custom container types: automatic AffinePath and TraversalPath generation for Either, Try, Validated, Map, arrays, and your own types
  • 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.


Five-Minute Focus DSL

If you only have a few minutes, this is the entire feature.

Step 1. Annotate two records:

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

@GenerateLenses @GenerateFocus
public record Address(String street, String city) {}

@GenerateLenses @GenerateFocus
public record User(String name, Address address) {}

Step 2. Use the generated UserFocus companion class:

User alice = new User("Alice", new Address("Old Street", "London"));

// Get
String city = UserFocus.address().city().get(alice);              // "London"

// Set
User moved  = UserFocus.address().city().set("Paris", alice);

// Modify
User shouty = UserFocus.address().city().modify(String::toUpperCase, alice);

Step 3. That's it. The path you typed (UserFocus.address().city()) is a typed value: you can store it, pass it around, and reuse it. The processor generated UserFocus and AddressFocus at compile time; nothing reflective happens at runtime.

For collections, sealed types, and Kind<F, A> fields, the same pattern extends through .each(), .some(), .at(i), and instanceOf(). The rest of this page walks through each in turn, but you almost never have to compose lenses manually to get useful work done.


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

See Also

  1. Navigation and Composition - Collection navigation, .via() composition, and generated navigators
  2. Type Class and Effect Integration - modifyF() foldMap(), traverseOver(), sum types, and Effect path bridging
  3. Custom Containers and Code Generation - Generated class structure, SPI container types, and registration
  4. Focus DSL Reference - Decision guide, common patterns, performance, pitfalls, and FAQ

Previous: Introduction Next: Navigation and Composition