Optics

A crystal ball revealing paths through nested data structures

"What we see depends mainly on what we look for."

– John Lubbock, The Beauties of Nature and the Wonders of the World We Live In


Immutable records in Java are safer, easier to reason about, and, when you need to change something inside them, a bit of an ordeal. Here is a 10% discount on every line of an order, as many Spring teams write it, with a wither on each record, the method Lombok's @With generates:

    // The withers Lombok's @With generates: each rebuilds only its own record, so the list is
    // rebuilt by hand
    Order discounted =
        order.withLines(
            order.lines().stream()
                .map(line -> line.withPrice(line.price().multiply(new BigDecimal("0.9"))))
                .toList());

A wither knows only its own record, so the list is streamed, each line rebuilt, and the order rebuilt around the new list by hand, and every other operation on the prices repeats that plumbing. Here is the same change through a generated path:

    // The Focus DSL: one generated path reaches every price, and rebuilds what it passes through
    Order discounted =
        OrderFocus.lines()
            .via(LineItemFocus.price())
            .modifyAll(price -> price.multiply(new BigDecimal("0.9")), order);

For Ada's order of a £40.00 lamp and four £2.50 bulbs, both versions price the lines at 36.000 and 2.250. The difference is what you can do next: name OrderFocus.lines().via(LineItemFocus.price()) once, and the same path rounds the prices on the Quickstart and checks them on Updates That Can Fail.

The annotation processor writes OrderFocus and LineItemFocus from @GenerateFocus on each record, which can sit beside Lombok's @With when Lombok comes first on the processor path. These records are the chapter's running cast, the order service the Mapping chapter's capstone also uses:

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

Every hop is an ordinary generated method, so the compiler checks the whole path and the IDE completes it for you. The path is also a value: store it in a field, pass it to a method, and reuse it to read, update, change every element of a list, or validate.

That path is an optic: a first-class, composable route from a whole structure to one or more of its parts. Lenses, prisms and traversals are the optics underneath, and the generated Focus paths are how you use them day to day. Sealed types (@GeneratePrisms), collections, and types you cannot modify (@ImportOptics, for Jackson, JOOQ and JDK types) get the same treatment. There is no reflection at runtime, and no hand-written composition unless you want it.

Before you start

Your project builds and runs on Java 25. Higher-Kinded-J is built on it today, and parts of the library use preview features, which tie the build to that one release. Most optics need no preview flag of their own, and Prerequisites lists the code that does. The HKJ Gradle or Maven plugin sets the flags and wires in the annotation processor. With Lombok in the build as well, its processor goes ahead of hkj-processor, as Build-time impact explains. The processor writes OrderFocus and its siblings when the project compiles, so until the first build an IDE shows them as missing.


How to read this chapter

Start from what you came for.

You wantStart at
A nested record updated, with the least readingQuickstart, then the Focus DSL
The everyday API, learned properlyThe pages under Ship in the Chapter Contents, read in order
An update that can fail, with every bad value reportedUpdates That Can Fail
A PATCH endpoint, or several edits applied as oneMany Edits at Once
To check what you have learned, and where to go nextCheck Your Understanding
A lens, prism, affine or iso explained in depthThe Optic Types
A traversal, fold, getter or setter explained in depthCollections
To judge whether optics fit your codebaseProduction Readiness
To bring a Lombok, stream, switch or Monocle habit acrossComing from Lombok, Streams and Switch, or Coming from Monocle or Haskell lens
An answer to a specific questionLook It Up
A domain record mapped to and from a wire DTOMapping at the Boundary, which needs none of this chapter first

How the optic types relate

Eight optic types, one shared supertype, and one real specialisation between them:

flowchart LR
    accTitle: How the eight optic types relate
    accDescr: Fold, Traversal, Setter, Lens, Affine, Prism and Iso each extend Optic directly. Getter extends Fold, the only inheritance between two optic types. Each optic is drawn in its legend shape.
    L(["Lens"]) --> O["Optic"]
    A(["Affine"]) --> O
    P@{ shape: hex, label: "Prism" } --> O
    I@{ shape: bow-rect, label: "Iso" } --> O
    T@{ shape: st-rect, label: "Traversal" } --> O
    F@{ shape: docs, label: "Fold" } --> O
    S@{ shape: lean-r, label: "Setter" } --> O
    G@{ shape: doc, label: "Getter" } --> F
    classDef root fill:#8caaee,stroke:#1e66f5,color:#232634
    classDef rw fill:#a6d189,stroke:#40a02b,color:#232634
    classDef maybe fill:#a6d189,stroke:#40a02b,color:#232634,stroke-dasharray:6 4
    classDef ro fill:#8caaee,stroke:#1e66f5,color:#232634
    class O root
    class L,P,T,I,S rw
    class A maybe
    class F,G ro

Each arrow reads extends: Fold extends Optic, and Getter extends Fold. That last is the only inheritance between two optic types. Everything else extends Optic directly, so they are siblings: a Lens is not a Fold, and lens.asFold() is an explicit conversion, one of those Conversions lists. What separates the types is capability: how many values an optic focuses, and whether you may write through it. Decision Trees turns those two questions into a choice.

Every diagram in the chapter draws an optic in the shape it has here. Fold and Getter, in blue, only read; Setter only writes; the others read and write, and the Affine's dashed outline marks a target that may be absent:

flowchart LR
    accTitle: The optic legend
    accDescr: Lens, exactly one, a rounded box; Affine, zero or one, a dashed rounded box; Prism, one variant, a hexagon; Traversal, zero or more, a stack of boxes; Iso, both ways, a box with curved ends; Getter, read one, a page; Fold, read many, a stack of pages; Setter, write many, a slanted box. Getter and Fold are blue and only read.
    L(["Lens<br/>exactly one"]) ~~~ A(["Affine<br/>zero or one"]) ~~~ P@{ shape: hex, label: "Prism<br/>one variant" } ~~~ T@{ shape: st-rect, label: "Traversal<br/>zero or more" }
    I@{ shape: bow-rect, label: "Iso<br/>both ways" } ~~~ G@{ shape: doc, label: "Getter<br/>read one" } ~~~ F@{ shape: docs, label: "Fold<br/>read many" } ~~~ S@{ shape: lean-r, label: "Setter<br/>write many" }
    classDef rw fill:#a6d189,stroke:#40a02b,color:#232634
    classDef maybe fill:#a6d189,stroke:#40a02b,color:#232634,stroke-dasharray:6 4
    classDef ro fill:#8caaee,stroke:#1e66f5,color:#232634
    class L,P,T,I,S rw
    class A maybe
    class F,G ro

Hands-On Learning

The Optics Tutorial Track (210 exercises) practises the chapter as exercises, from Lens & Prism through the Focus DSL to batching and the generated DTO boundary.

Chapter Contents

Ship, read in order:

  1. Quickstart: Three runnable examples in 100 lines
  2. Focus DSL: Generated paths through your own records
  3. Collections, Optionals and Sealed Types: Step into lists, maps, optionals and variants
  4. What a Path Is Made Of: The optics a path wraps, and when you need one
  5. Updates That Can Fail: Validated updates, every error reported
  6. Many Edits at Once: Several edits as one, and REST PATCH
  7. Capstone: An Order Desk: Everything so far, on one order service
  8. Check Your Understanding: Twelve questions, and where to go next

On demand, when a task calls for it:

  1. The Optic Types: Lens, Prism, Affine and Iso, a page each
  2. Collections: Traversal, Fold, Getter and Setter
  3. Precision and Filtering: Filtered, indexed and per-key access
  4. The Focus DSL in Depth: Effects, custom containers and Kind fields
  5. Optics for External Types: Jackson, JOOQ, Lombok and other types you do not own
  6. Validation, Batching and Auditing: Validated prisms, modifyF pipelines, batching and audit trails
  7. Programs as Data: The Free Monad DSL and its interpreters

Look it up, when you hold a question:

  1. Look It Up: Production readiness, decision trees, the cookbook and reference tables

Next: Quickstart