Focus DSL: Path-Based Optic Syntax

Navigate and update nested records through generated, compile-checked paths that mirror the shape of your data.

What You'll Learn

  • Read, set and modify a field anywhere in your records through a generated path
  • Predict the path type a field gives you, and spell the next hop
  • Compose paths with .via() where a navigator does not reach
  • Hand a path's optic to an API that takes a raw optic

See Example Code

The code on this page is FocusDslBook.java and its FocusDslBookTest.java: the page includes them, so the build compiles and runs them.

For longer programs in the same style, see NavigatorExample and ContainerNavigationExample.

A Focus path does for your records what the JPA metamodel or QueryDSL's Q-types do for a query, and it writes as well as reads. OrderFocus.customer().name() names a field the way QOrder.order.customer.name does, and the compiler checks every step. Instead of composing lenses, prisms and traversals by hand, you navigate your data with method chains that mirror its shape.


The whole feature on one screen

Step 1. Annotate the records. These are the chapter's cast, which the Quickstart introduced: an Order placed by a Customer.

The cast these examples use

@GenerateLenses
@GenerateFocus
@GenerateTraversals
public record Order(
    UUID id,
    Customer customer,
    List<LineItem> lines,
    Instant placedAt,
    Currency currency,
    OrderStatus status) {}
@GenerateLenses
@GenerateFocus
public record Customer(String name, EmailAddress email) {}

Step 2. Use the generated OrderFocus companion class:

    String name = OrderFocus.customer().name().get(order);

    Order renamed = OrderFocus.customer().name().set("Ada Lovelace", order);

    Order tidied = OrderFocus.customer().name().modify(String::strip, order);

Step 3. That is it. The path you typed (OrderFocus.customer().name()) is a typed value: store it, pass it around, reuse it. The processor generated OrderFocus and CustomerFocus at compile time; nothing reflective happens at runtime.

.name() chains straight off .customer() because Customer is another annotated record, so customer() returns a navigator. A navigator is the field's own path, a FocusPath<Order, Customer>, with a method added for each field of the record it reaches. It goes wherever a FocusPath goes, and .via(CustomerFocus.name()) spells the same hop. Navigators are on by default, and @GenerateFocus(generateNavigators = false) turns them off for a record.

Collections, optionals and sealed types extend the same pattern, and Find your field gives the spelling for each. You almost never have to compose lenses by hand to get useful work done.

Why this matters

The field's declared type decides the path type at compile time, so the path says how many values it can reach. An Optional, a Maybe or a field with a recognised @Nullable gives an AffinePath, never a FocusPath whose get quietly returns null. A List, Set or Collection gives a TraversalPath, which has no singular get. Mistakes of that shape are compilation errors, not production ones. The guarantee covers the shapes the processor recognises. A field that holds null without saying so gets a plain FocusPath, and so does a Map or an array of a reference type unless the annotation sets widenCollections = true; Find your field marks each.


Find your field

You arrive holding a field, not an optic type. Find its declaration in the table: the row gives what the generated method returns and what you write next. Every row but the last is a component of one Basket: the basket a customer pays for before it becomes an Order, given a field of every kind around the cast's own records. The collapsed proof compiles each one. Each field links to the section that explains it.

Your fieldThe generated method returnsWhat you write next
Customer customer, a record with @GenerateFocusa navigator, which is also a FocusPath<Basket, Customer>.email()
String referenceFocusPath<Basket, String>.get(basket), .set(value, basket), .modify(f, basket)
List<LineItem> lines, or a Set or Collectiona navigator, which is also a TraversalPath<Basket, LineItem>, already on the elements.quantity()
Optional<String> giftMessageAffinePath<Basket, String>.getOptional(basket)
@Nullable String couponCode, with a recognised @NullableAffinePath<Basket, String>.getOptional(basket)
String legacyNote, which may hold null but is not annotatedFocusPath<Basket, String>.nullable()
Map<String, String> attributesFocusPath<Basket, Map<String, String>>, by default.via(FocusPaths.mapAt("channel"))
String[] tagsFocusPath<Basket, String[]>, by default.each(EachInstances.arrayEach())
Either<String, String> approvedBy, or a Maybe, Try or ValidatedAffinePath<Basket, String>, on the success side.getOptional(basket)
Payment payment, a sealed interfaceFocusPath<Basket, Payment>.via(PaymentPrisms.card()), or .via(AffinePath.instanceOf(Card.class))
JsonNode payload, a type you cannot annotateFocusPath<Basket, JsonNode>.via(JsonNodeOptics.object()), from @ImportOptics
Kind<F, A> itemsa path chosen by the witnessdepends on the witness

The two rows marked "by default" step into the elements instead when the annotation sets widenCollections = true, or names the field in widenFields, which the fine print covers.

Proof: every row, compiled

// The basket a customer pays for before it becomes an Order, with a field of every kind
@GenerateFocus
record Basket(
    Customer customer,
    String reference,
    List<LineItem> lines,
    Optional<String> giftMessage,
    @Nullable String couponCode,
    String legacyNote,
    Map<String, String> attributes,
    String[] tags,
    Either<String, String> approvedBy,
    Payment payment,
    JsonNode payload) {}
    // A record with @GenerateFocus: with navigators on, the next field chains straight on
    FocusPath<Basket, String> customerEmail = BasketFocus.customer().email().value();

    // A plain value: read and write it
    FocusPath<Basket, String> reference = BasketFocus.reference();

    // A List, Set or Collection of records: a navigator already on the elements
    TraversalPath<Basket, Integer> quantities = BasketFocus.lines().quantity();

    // An Optional, or a component with a recognised @Nullable: zero or one
    AffinePath<Basket, String> giftMessage = BasketFocus.giftMessage();
    AffinePath<Basket, String> couponCode = BasketFocus.couponCode();

    // A reference that may hold null, with no annotation: say so with .nullable()
    AffinePath<Basket, String> legacyNote = BasketFocus.legacyNote().nullable();

    // A Map: the path focuses the whole map, and mapAt(k) picks one value
    AffinePath<Basket, String> channel = BasketFocus.attributes().via(FocusPaths.mapAt("channel"));

    // An array: the path focuses the whole array, and .each(...) steps into it
    TraversalPath<Basket, String> tags = BasketFocus.tags().each(EachInstances.arrayEach());

    // An Either, Maybe, Try or Validated: zero or one, on the success side
    AffinePath<Basket, String> approvedBy = BasketFocus.approvedBy();

    // A sealed type: a generated prism picks one variant, or instanceOf by runtime type
    AffinePath<Basket, Card> card = BasketFocus.payment().via(PaymentPrisms.card());
    AffinePath<Basket, Card> sameCard =
        BasketFocus.payment().via(AffinePath.instanceOf(Card.class));

    // A type you cannot annotate: compose the optics @ImportOptics generated for it
    AffinePath<Basket, ObjectNode> payloadObject =
        BasketFocus.payload().via(JsonNodeOptics.object());

Collections, Optionals and Sealed Types explains each row in depth: which fields get a navigator, and how a path widens as it goes.


What the processor gives you

Annotate the records

Add @GenerateFocus to generate path builders. The Focus class builds its own lenses, so @GenerateFocus alone compiles. Add @GenerateLenses as well in practice: several idioms in this chapter start from FocusPath.of(TheseLenses.field()), which needs the generated lens class. Indexing a list is one, and decomposing it into its head and tail with ListPrisms another.

The examples that follow use the cast's Order and LineItem, and a CustomerProfile: a customer's account profile, whose nickname and second email are optional.

@GenerateLenses
@GenerateFocus
public record CustomerProfile(
    String name, Optional<String> nickname, Optional<EmailAddress> altEmail) {}

One method per component, and the field type picks the path

The generated companion has one method per record component, and the field's type decides the path type you get back:

    // A plain field: exactly one focus
    FocusPath<Order, OrderStatus> statusPath = OrderFocus.status();
    OrderStatus status = statusPath.get(order);

    // A List field: the processor has already stepped into the elements
    TraversalPath<Order, LineItem> linePath = OrderFocus.lines();
    List<LineItem> lines = linePath.getAll(order);

    // An Optional field: zero or one focus
    AffinePath<CustomerProfile, String> nicknamePath = CustomerProfileFocus.nickname();
    Optional<String> nickname = nicknamePath.getOptional(profile);

A collection field is already element-level

OrderFocus.lines() focuses each LineItem, not the List<LineItem>. That is what you want for bulk reads and updates, but it means an operation on the list as a whole, such as indexing into it or taking its head, does not compose onto it. When you need the list itself, start from the lens instead: FocusPath.of(OrderLenses.lines()). See Collections, Optionals and Sealed Types for the indexing forms.

Chain with .via()

Paths compose with .via(). Composing a path with a wider one widens the result, so one focus joined to many focuses is many focuses, and anything joined to a traversal is a traversal. The navigator OrderFocus.lines().sku() is this same path, written as a method call:

    TraversalPath<Order, String> skus = OrderFocus.lines().via(LineItemFocus.sku());

    // Read every one of them
    List<String> all = skus.getAll(order);

    // Or update every one of them
    Order lowered = skus.modifyAll(String::toLowerCase, order);

The Three Path Types

Focus DSL provides three path types, one for each answer to "how many values does this path reach?". Each navigation hop keeps the count or widens it, and headOption() is the one step back, from many to at most one:

Path typeReachesAfter an optional stepAfter a collection step
FocusPath<S, A>exactly one valueAffinePathTraversalPath
AffinePath<S, A>zero or one valueAffinePathTraversalPath
TraversalPath<S, A>zero or more valuesTraversalPathTraversalPath

FocusPath: Exactly One Element

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

    FocusPath<Customer, String> namePath = CustomerFocus.name();

    String name = namePath.get(customer); // always a value
    Customer updated = namePath.set("Grace", customer); // always succeeds
    Customer modified = namePath.modify(String::toUpperCase, customer);

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 the underlying optic

AffinePath: Zero or One Element

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

    AffinePath<CustomerProfile, String> nicknamePath = CustomerProfileFocus.nickname();

    Optional<String> nickname = nicknamePath.getOptional(profile); // may be empty
    CustomerProfile updated = nicknamePath.set("Countess", profile); // writes even when absent
    CustomerProfile modified = nicknamePath.modify(String::toUpperCase, profile);
    boolean hasNickname = nicknamePath.matches(profile);

Key Operations:

MethodReturn TypeDescription
getOptional(S)Optional<A>Extract if present
set(A, S)SWrite the value, creating the focus when the last step can build it and every earlier step is present
modify(Function<A,A>, S)STransform if present
matches(S)booleanCheck whether a value is in focus
getOrElse(A, S)AExtract, or the given default
toAffine()Affine<S, A>Extract the underlying optic

Set on an absent focus writes anyway

set through an AffinePath is not conditional. CustomerProfileFocus.nickname().set(x, profile) on a profile with no nickname returns a profile with that nickname, because the last step's setter rebuilds the present case unconditionally. As Affine.set's javadoc puts it, an affine makes an absent focus present when its last step can build the value and every step before that is present. modify is the operation that no-ops on an absent focus.

The rule is positional. A miss at the last step writes through and creates the focus when that step can build the value (a prism, .some(FocusPaths.optionalSome()), .nullable()). A miss at an earlier step of a multi-step path skips the whole set, because Affine.andThen(Affine) does guard. When absence must be preserved, reach for modify, or test with matches first.

TraversalPath: Zero or More Elements

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

    TraversalPath<Order, LineItem> linesPath = OrderFocus.lines();

    List<LineItem> all = linesPath.getAll(order);
    Order updated = linesPath.setAll(replacement, order);
    Order modified =
        linesPath.modifyAll(line -> LineItemLenses.quantity().modify(q -> q + 1, line), order);
    int lineCount = linesPath.count(order);

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>Narrow to the matching elements (on a raw Traversal the same narrowing is spelled filtered)
preview(S)Optional<A>The first focused value, if there is one and it is not null
count(S), isEmpty(S)int, booleanQuery the number in focus
exists(Predicate<A>, S), all(Predicate<A>, S)booleanDoes any, or every, focused value match
find(Predicate<A>, S)Optional<A>The first focused value that matches and is not null
fold(Monoid<A>, S)ACombine every focused value through a monoid
headOption()AffinePath<S, A>Narrow to the first focused element (reads the first, writes to all)
toTraversal()Traversal<S, A>Extract the underlying optic

You can ship now

You can now find the path for any field you hold, read and write through it, and chain it into the records it reaches. The next page covers the hops you spell yourself, into collections, optional values and sealed types.

Checkpoint: set two steps down

A CustomerProfile has no second email. What does CustomerProfileFocus.altEmail().via(EmailAddressFocus.value()).set("ada@work.example", profile) return?

Answer and why

The profile unchanged. The rule is positional: the miss is at the Optional, an earlier step, and the last step, the address's value, cannot build an EmailAddress from nothing, so the whole set is skipped. CustomerProfileFocus.altEmail().set(...) alone would write, because its last step can build the value.

Where this lives: AffinePath: Zero or One Element.

Checkpoint: predict the path type

Consignment carries @GenerateFocus and has two components: Address to, where Address carries @GenerateFocus, and a sealed ConsignmentState state. What do ConsignmentFocus.to() and ConsignmentFocus.state() return? And what does CustomerProfileFocus.altEmail() return for an Optional<EmailAddress>?

Answer and why

A navigator for Address, so .city() chains straight on; a FocusPath<Consignment, ConsignmentState>, because a sealed field is not narrowed until a prism picks a variant; and a navigator for EmailAddress. That last is an AffinePath<CustomerProfile, EmailAddress>, on which .value() chains.

Where this lives: Find your field, and Which fields get a navigator.


Key Takeaways

  • The field type picks the path type. A plain field gives FocusPath, an Optional field gives AffinePath, a List or Set field gives TraversalPath already stepped into the elements.
  • .via() is the universal join. Navigators are generated sugar for a subset of fields; everything else composes with .via(), and the two mix freely. Collections, Optionals and Sealed Types sets out which fields qualify.
  • Composing widens. Composing a path with a wider one widens the result: one focus plus zero-or-one is zero-or-one, and anything joined to a traversal is a traversal. headOption() is the one step back.
  • A path is a value, not a call. Build it once, store it in a static field, pass it around. toLens(), toAffine() and toTraversal() hand the underlying optic to any API that wants a raw optic.

Hands-On Learning

See Also


Previous: Quickstart Next: Collections, Optionals and Sealed Types