Optics Quickstart

Change a field three records deep in one line, then a list, a sealed type and a Jackson tree.

What You'll Learn

  • Change a field three records deep with one generated path
  • Apply one change to every element of a list inside a record
  • Match one variant of a sealed type, and move to another without a modify
  • Generate optics for a type you cannot annotate, such as Jackson's JsonNode

See Example Code

The code on this page is QuickstartBook.java and its QuickstartBookTest.java: the page includes them directly, so the build compiles and runs them.

Your project needs Java 25 and the annotation processor hkj-processor, which the HKJ build plugin wires in; the chapter introduction sets both out.

You do not need to understand higher-kinded types, profunctors, or Applicative to use any of the code on this page. The annotations generate plain Java classes you call with familiar method chains.


1. Annotate, then update

The chapter's running cast is an order service. Annotate each record:

@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) {}
@GenerateLenses
@GenerateFocus
public record EmailAddress(String value) {}

Then a field three records deep, the customer's email address, is one line:

    Order updated = OrderFocus.customer().email().value().set("ada@example.org", order);

The annotation processor runs at compile time and produces OrderLenses, OrderFocus, CustomerLenses, CustomerFocus, EmailAddressLenses and EmailAddressFocus for you, and OrderTraversals for the list of lines. There is no reflection at runtime; the path you wrote is just a chain of typed method calls.

Each of those methods returns a lens: a record's accessor paired with a copy that replaces that one component through the canonical constructor. A path joins lenses end to end, so a write three records down rebuilds each record on the way, and reuses everything off the path.

What the processor wrote

A generated lens class is plain Java. This one comes from the processor's own tests, for a four-field Customer(id, name, email, loyaltyPoints) rather than the cast's: it holds one static method per component and a withX helper per component. The processor's tests compare this file line for line against what it generates. It was generated through @ImportOptics, which is why it has a package of its own; @GenerateLenses writes the same class into the record's package.

// Generated by hkj-optics-processor. Do not edit.
package com.test.optics;

import com.test.Customer;
import java.lang.Integer;
import java.lang.String;
import org.higherkindedj.optics.Lens;
import org.higherkindedj.optics.annotations.Generated;

/**
 * Generated optics for {@link Customer}. Do not edit.
 */
@Generated
public final class CustomerLenses {
  private CustomerLenses() {
  }

  /**
   * Creates a {@link Lens} for the {@code id} field of a {@link Customer}.
   *
   * @return A non-null {@code Lens<Customer, String>}.
   */
  public static Lens<Customer, String> id() {
    return Lens.of(Customer::id, (source, newValue) -> new Customer(newValue, source.name(), source.email(), source.loyaltyPoints()));
  }

  /**
   * Creates a {@link Lens} for the {@code name} field of a {@link Customer}.
   *
   * @return A non-null {@code Lens<Customer, String>}.
   */
  public static Lens<Customer, String> name() {
    return Lens.of(Customer::name, (source, newValue) -> new Customer(source.id(), newValue, source.email(), source.loyaltyPoints()));
  }

  /**
   * Creates a {@link Lens} for the {@code email} field of a {@link Customer}.
   *
   * @return A non-null {@code Lens<Customer, String>}.
   */
  public static Lens<Customer, String> email() {
    return Lens.of(Customer::email, (source, newValue) -> new Customer(source.id(), source.name(), newValue, source.loyaltyPoints()));
  }

  /**
   * Creates a {@link Lens} for the {@code loyaltyPoints} field of a {@link Customer}.
   *
   * @return A non-null {@code Lens<Customer, Integer>}.
   */
  public static Lens<Customer, Integer> loyaltyPoints() {
    return Lens.of(Customer::loyaltyPoints, (source, newValue) -> new Customer(source.id(), source.name(), source.email(), newValue));
  }

  /**
   * Creates a new {@link Customer} instance with an updated {@code id} field.
   * <p>This is a convenience method that uses the {@link #id()} lens.
   *
   * @param source The original {@code Customer} instance.
   * @param newId The new value for the {@code id} field.
   * @return A new, updated {@code Customer} instance.
   */
  public static Customer withId(Customer source, String newId) {
    return id().set(newId, source);
  }

  /**
   * Creates a new {@link Customer} instance with an updated {@code name} field.
   * <p>This is a convenience method that uses the {@link #name()} lens.
   *
   * @param source The original {@code Customer} instance.
   * @param newName The new value for the {@code name} field.
   * @return A new, updated {@code Customer} instance.
   */
  public static Customer withName(Customer source, String newName) {
    return name().set(newName, source);
  }

  /**
   * Creates a new {@link Customer} instance with an updated {@code email} field.
   * <p>This is a convenience method that uses the {@link #email()} lens.
   *
   * @param source The original {@code Customer} instance.
   * @param newEmail The new value for the {@code email} field.
   * @return A new, updated {@code Customer} instance.
   */
  public static Customer withEmail(Customer source, String newEmail) {
    return email().set(newEmail, source);
  }

  /**
   * Creates a new {@link Customer} instance with an updated {@code loyaltyPoints} field.
   * <p>This is a convenience method that uses the {@link #loyaltyPoints()} lens.
   *
   * @param source The original {@code Customer} instance.
   * @param newLoyaltyPoints The new value for the {@code loyaltyPoints} field.
   * @return A new, updated {@code Customer} instance.
   */
  public static Customer withLoyaltyPoints(Customer source, int newLoyaltyPoints) {
    return loyaltyPoints().set(newLoyaltyPoints, source);
  }
}

Why two annotations?

@GenerateLenses produces classic lenses (OrderLenses.customer()) plus withFoo helpers for shallow updates. @GenerateFocus adds the path-based DSL (OrderFocus.customer()) for deep navigation, and its navigators let the next hop chain off it as .email() rather than .via(CustomerFocus.email()). Most records benefit from both annotations.


2. Sum types and collections, the same way

Collection fields and sealed interfaces use the same annotation-driven pattern. An order's lines are a List<LineItem>:

@GenerateLenses
@GenerateFocus
public record LineItem(String sku, Integer quantity, BigDecimal price) {}

Round every price to pence:

    Order rounded =
        OrderFocus.lines()
            .price()
            .modifyAll(price -> price.setScale(2, RoundingMode.HALF_EVEN), order);

OrderFocus.lines() already walks every element of the List<LineItem>: the generated accessor ends in .each(EachInstances.listEach()) for you, so it hands back a path focused on a LineItem, not on the list. It is a navigator, as customer() is in section 1, so .price() zooms each element down to the price field. modifyAll applies the function in one pass to return a new Order. The introduction's discount left the lines at 36.000 and 2.250; rounding gives 36.00 and 2.25.

Do not add your own each

OrderFocus.lines().each(EachInstances.listEach()) does not compile: the path already focuses a LineItem, and a LineItem is not a List. The deprecated no-argument .each() does compile, because its element type is inferred from whatever you assign it to, and then fails at run time with a ClassCastException.

Match a variant, and move to another. A consignment's state is a sealed interface, and @GeneratePrisms writes one prism per variant:

/** Where a consignment has got to. */
@GeneratePrisms
public sealed interface ConsignmentState
    permits ConsignmentState.Pending, ConsignmentState.Dispatched, ConsignmentState.Returned {

  record Pending() implements ConsignmentState {}

  @GenerateFocus
  record Dispatched(Instant at) implements ConsignmentState {}

  @GenerateFocus
  record Returned(String reason) implements ConsignmentState {}
}
@GenerateLenses
@GenerateFocus
public record Consignment(UUID orderId, Address to, ConsignmentState state) {}
    boolean isPending = ConsignmentStatePrisms.pending().matches(consignment.state());

    // modify rebuilds the variant it narrowed to, so the function is Returned -> Returned
    ConsignmentState tidied =
        ConsignmentStatePrisms.returned()
            .modify(
                returned -> new ConsignmentState.Returned(returned.reason().strip()),
                consignment.state());

    // Moving to a different variant is not a modify. Read through the prism, then build.
    ConsignmentState dispatched =
        ConsignmentStatePrisms.pending()
            .getOptional(consignment.state())
            .<ConsignmentState>map(_ -> new ConsignmentState.Dispatched(dispatchedAt))
            .orElse(consignment.state());

A Prism is the sum-type counterpart of a lens: it succeeds when the variant matches and is a no-op otherwise. Note what modify will and will not do. Its function is A -> A, so it rebuilds the same variant; changing Pending into Dispatched is a read followed by a build, not a modification.

Two views of the same record

We added three annotations to Order. They don't conflict; each generates its own companion class (OrderLenses, OrderFocus, OrderTraversals) and you pick the entry point that matches your task.


3. Annotating types you don't own

External types like JDK classes, Jackson nodes, JOOQ records, and Protobuf messages can't be annotated directly. Higher-Kinded-J solves this with OpticsSpec: you declare the optics you want as an interface, and the processor generates them. Here is one for Jackson 3's JsonNode:

@ImportOptics
public interface JsonNodeOpticsSpec extends OpticsSpec<JsonNode> {

  @InstanceOf(ObjectNode.class)
  Prism<JsonNode, ObjectNode> object();

  @InstanceOf(ArrayNode.class)
  Prism<JsonNode, ArrayNode> array();

  @InstanceOf(StringNode.class)
  Prism<JsonNode, StringNode> text();

  @InstanceOf(NumericNode.class)
  Prism<JsonNode, NumericNode> numeric();

  @InstanceOf(BooleanNode.class)
  Prism<JsonNode, BooleanNode> bool();
}

The processor reads the spec and generates a JsonNodeOptics class (the Spec suffix is dropped) with one prism per method, each backed by an instanceof pattern match:

    JsonNode response = new ObjectMapper().readTree(json);

    Optional<ArrayNode> items = JsonNodeOptics.array().getOptional(response.get("items"));

    Optional<StringNode> firstName =
        items
            .flatMap(array -> JsonNodeOptics.object().getOptional(array.get(0)))
            .flatMap(object -> JsonNodeOptics.text().getOptional(object.get("name")));

Plain Java wins here

For one read like this, Jackson's own response.at("/items/0/name") is shorter. The prisms earn their place when they compose with the rest of your optics, so that a JSON tree joins the same paths as your records. Taming JSON with Jackson shows that, and how @MatchWhen handles Jackson's predicate-based type checks.

You can ship now

You can now change a field three records deep, every element of a list, and one variant of a sealed type, through paths the processor generates. The rest of the chapter is for when a task needs more, starting with the Focus DSL in full.

Checkpoint: what set leaves behind

After this line, what does order.customer().email().value() hold, and is updated.lines() the same list as order.lines()?

    Order updated = OrderFocus.customer().email().value().set("ada@example.org", order);

Answer and why

The old address, ada@example.com, and yes. set changes nothing in place: it returns a new Order, rebuilding each record on the path and reusing everything off it, the lines included.

Where this lives: Annotate, then update.

Checkpoint: modify on another variant

A consignment is Pending. What does ConsignmentStatePrisms.returned().modify(_ -> new ConsignmentState.Returned("lost"), consignment.state()) return?

Answer and why

The Pending state, unchanged. The prism focuses only a Returned, so on any other variant modify has nothing to change, and hands the value back as it was. Moving a consignment to another variant is a read through the prism followed by a build.

Where this lives: Sum types and collections, the same way.


Key Takeaways

  • The annotations generate plain Java at compile time. XLenses, XFocus, XPrisms and XTraversals are ordinary classes you call with ordinary method chains. Nothing here uses runtime reflection.
  • @GenerateLenses and @GenerateFocus are the pair to reach for. Lenses give you withFoo and the classic optics; Focus gives you the path DSL, whose navigators let a hop into another record chain as a method call.
  • A collection accessor is already element-level. OrderFocus.lines() focuses a LineItem, because the generated method ends in .each(EachInstances.listEach()) for you. Adding another does not compile.
  • A prism rebuilds the variant it narrowed to. modify is A -> A, so it cannot turn a Pending into a Dispatched; that is a read through the prism followed by building the new variant.
  • Types you do not own join the same vocabulary. An OpticsSpec interface plus @ImportOptics generates optics for JsonNode, JOOQ records or JDK types, and they compose with everything else.

See Also

Hands-On Learning

Practise this page on the same order in Tutorial 00: Your First Path (3 exercises), which also checks that your build is set up.

Ready for hands-on?

The Optics Tutorial Track (210 exercises) is exercise-driven. Six journeys run from Lens & Prism through the Focus DSL to batching, coupled updates, and the generated DTO boundary. Recommended once you've finished this Quickstart.


Previous: Optics Next: Focus DSL