Focus DSL: Path-Based Optic Syntax
Navigate and update nested records through generated, compile-checked paths that mirror the shape of your data.
- 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
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
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.
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 field | The generated method returns | What you write next |
|---|---|---|
Customer customer, a record with @GenerateFocus | a navigator, which is also a FocusPath<Basket, Customer> | .email() |
String reference | FocusPath<Basket, String> | .get(basket), .set(value, basket), .modify(f, basket) |
List<LineItem> lines, or a Set or Collection | a navigator, which is also a TraversalPath<Basket, LineItem>, already on the elements | .quantity() |
Optional<String> giftMessage | AffinePath<Basket, String> | .getOptional(basket) |
@Nullable String couponCode, with a recognised @Nullable | AffinePath<Basket, String> | .getOptional(basket) |
String legacyNote, which may hold null but is not annotated | FocusPath<Basket, String> | .nullable() |
Map<String, String> attributes | FocusPath<Basket, Map<String, String>>, by default | .via(FocusPaths.mapAt("channel")) |
String[] tags | FocusPath<Basket, String[]>, by default | .each(EachInstances.arrayEach()) |
Either<String, String> approvedBy, or a Maybe, Try or Validated | AffinePath<Basket, String>, on the success side | .getOptional(basket) |
Payment payment, a sealed interface | FocusPath<Basket, Payment> | .via(PaymentPrisms.card()), or .via(AffinePath.instanceOf(Card.class)) |
JsonNode payload, a type you cannot annotate | FocusPath<Basket, JsonNode> | .via(JsonNodeOptics.object()), from @ImportOptics |
Kind<F, A> items | a path chosen by the witness | depends 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
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);
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 type | Reaches | After an optional step | After a collection step |
|---|---|---|---|
FocusPath<S, A> | exactly one value | AffinePath | TraversalPath |
AffinePath<S, A> | zero or one value | AffinePath | TraversalPath |
TraversalPath<S, A> | zero or more values | TraversalPath | TraversalPath |
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:
| Method | Return Type | Description |
|---|---|---|
get(S) | A | Extract the focused value |
set(A, S) | S | Replace the focused value |
modify(Function<A,A>, S) | S | Transform 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:
| Method | Return Type | Description |
|---|---|---|
getOptional(S) | Optional<A> | Extract if present |
set(A, S) | S | Write the value, creating the focus when the last step can build it and every earlier step is present |
modify(Function<A,A>, S) | S | Transform if present |
matches(S) | boolean | Check whether a value is in focus |
getOrElse(A, S) | A | Extract, or the given default |
toAffine() | Affine<S, A> | Extract the underlying optic |
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:
| Method | Return Type | Description |
|---|---|---|
getAll(S) | List<A> | Extract all focused values |
setAll(A, S) | S | Replace all focused values |
modifyAll(Function<A,A>, S) | S | Transform 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, boolean | Query the number in focus |
exists(Predicate<A>, S), all(Predicate<A>, S) | boolean | Does 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) | A | Combine 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 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.
A CustomerProfile has no second email. What does CustomerProfileFocus.altEmail().via(EmailAddressFocus.value()).set("ada@work.example", profile) return?
Answer and why
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.
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
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.
- The field type picks the path type. A plain field gives
FocusPath, anOptionalfield givesAffinePath, aListorSetfield givesTraversalPathalready 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()andtoTraversal()hand the underlying optic to any API that wants a raw optic.
- Tutorial12_FocusDSL.java (10 exercises)
- Tutorial13_AdvancedFocusDSL.java (8 exercises)
- Tutorial19_NavigatorGeneration.java (8 exercises)
- Collections, Optionals and Sealed Types: collection navigation,
.via()composition, and generated navigators - Type Class and Effect Integration:
modifyF(),foldMap(),EachInstances.fromTraverse(), and Effect path bridging - Custom Containers and Code Generation: generated class structure, SPI container types, and registration
- Focus DSL Reference: decision guide, common patterns, pitfalls, and FAQ
Previous: Quickstart Next: Collections, Optionals and Sealed Types