What a Path Is Made Of

Every path wraps an optic: take it out, compose it with andThen, and know when you need it.

A camera lens, close up against a dark background

What You'll Learn

  • Name the optic each path type wraps, and take it out
  • Write a lens by hand, and see that it is an accessor and a copy
  • Compose optics with andThen, and predict the type it returns
  • Choose an optic by how many values it reaches and whether it writes
  • Know when to hand over the optic rather than the path

See Example Code

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

Nearly every update so far has been a path, such as UserFocus.address().street().name(). A path is a thin wrapper around an optic: an object that reads one or more parts of a structure, and rebuilds the structure with them replaced. Most code never opens the wrapper. This page opens it for the few jobs that need it. When you need the optic lists them: an API that takes an optic, a type the processor cannot reach, an optic with no path type, and a compiler message that names an optic type.


Each path type wraps an optic

Each of the three path types wraps the optic of the same reach: a FocusPath a Lens, an AffinePath an Affine, and a TraversalPath a Traversal. Each hands its optic over with one call. The records are the Quickstart's, and an order with a sealed payment:

@GenerateLenses
@GenerateFocus(generateNavigators = true)
record Street(String name, int number) {}

@GenerateLenses
@GenerateFocus(generateNavigators = true)
record Address(Street street, String city) {}

@GenerateLenses
@GenerateFocus(generateNavigators = true)
record User(String name, Address address) {}

@GeneratePrisms
sealed interface Payment permits Payment.Card, Payment.Invoice {
  record Card(String last4) implements Payment {}

  record Invoice(String terms) implements Payment {}
}

@GenerateLenses
@GenerateFocus
record LineItem(String sku, int quantity) {}

@GenerateLenses
@GenerateFocus
@GenerateTraversals
record Order(String id, Payment payment, List<LineItem> lines) {}

Each path gives up its optic:

    FocusPath<User, String> streetPath = UserFocus.address().street().name();
    Lens<User, String> streetName = streetPath.toLens();

    AffinePath<Order, Payment.Card> cardPath = OrderFocus.payment().via(PaymentPrisms.card());
    Affine<Order, Payment.Card> card = cardPath.toAffine();

    TraversalPath<Order, Integer> quantityPath = OrderFocus.lines().via(LineItemFocus.quantity());
    Traversal<Order, Integer> quantities = quantityPath.toTraversal();

The optic reads and writes exactly what its path does: setting the street name through streetName gives the same User as setting it through streetPath. FocusPath.of(lens) goes the other way, and wraps an optic you already hold in a path.


What a lens is

A Lens<S, A> is the pair you would write by hand for one record component: the accessor, and a copy that replaces that component. Lens.of takes exactly those two functions:

    Lens<User, Address> address =
        Lens.of(User::address, (user, newAddress) -> new User(user.name(), newAddress));

UserLenses.address() is this lens, generated, and a generated lens class holds one per component, as the Quickstart's What the processor wrote shows. A lens is a wither you can pass around, and it does what a wither cannot: compose. Why a lens, when you have @With? makes that case in full.


Composing optics with andThen

andThen joins two optics end to end, as .via(...) joins a path to its next hop. The result reaches as many values as the two steps allow together. A lens then a lens is a Lens. A lens then a zero-or-one step is an Affine, even when that step is a Prism. Any of the five read-write optics followed by a Traversal is a Traversal. An Iso takes the type of whatever follows it:

    // exactly one, then exactly one: still exactly one
    Lens<User, String> streetName =
        UserLenses.address().andThen(AddressLenses.street()).andThen(StreetLenses.name());

    // exactly one, then one variant: zero or one
    Affine<Order, Payment.Card> card = OrderLenses.payment().andThen(PaymentPrisms.card());

    // zero or more, then exactly one: zero or more
    Traversal<Order, Integer> quantities =
        OrderTraversals.lines().andThen(LineItemLenses.quantity());

.via(...) on a path is andThen on the optic inside, so OrderFocus.payment().via(PaymentPrisms.card()) and OrderLenses.payment().andThen(PaymentPrisms.card()) find the same card. The composed streetName is the copy-and-rebuild cascade written once: a set through it rebuilds the Street, the Address and the User, and reuses every value off the path. Composition Rules gives the result type for every pair.


Choosing an optic

Two questions pick the optic: how many values it reaches, and whether you may write through it.

exactly onezero or onezero or more
read and writeLens, or an Iso when it converts both waysAffine, or a Prism when it picks a variant of a sealed typeTraversal
read onlyGettera Fold that finds at most oneFold
write onlySetter

Each plays the part of a Java idiom you already write:

OpticPlays the part ofWhat it adds
Lensan accessor and a withX copy, as one valuecomposes through nested records
Affinean accessor that returns Optional, with a copy that writes the valuecomposes, and modify leaves an absent value alone
Prisman instanceof pattern, and the variant's constructorcomposes, and builds the variant back
Isoa wrapper record's constructor and accessor, such as new Sku(text) and sku.text(), which lose nothingcomposes, and turns around with reverse()
TraversalreplaceAll over a list inside a record, returning a new recordreaches every element in one pass, through nested lists
Folda Stream over the same values, which only readscomposes, and says in its type that it never writes
Gettera derived accessor, such as a fullName() computed from two fieldscomposes with other getters, and with any optic through asFold()
SetterreplaceAll with nothing to read backcomposes, for a value you change without reading

The chapter introduction draws how the eight types relate, and Decision Trees asks the same two questions as a tree.


Using an optic directly

A raw optic carries the operations its type allows. A Traversal is the exception that surprises people: its reads and writes live in the Traversals utility rather than on the traversal itself, and it reads as a Fold through asFold():

    Order doubled = Traversals.modify(quantities, quantity -> quantity * 2, order);

    int totalQuantity =
        quantities.asFold().foldMap(Monoids.integerAddition(), quantity -> quantity, order);

    Optional<Payment.Card> paidByCard = card.getOptional(order);

For an order of quantities 1 and 2 paid by card, doubled holds 2 and 4, totalQuantity is 3, and paidByCard holds the card. A TraversalPath has modifyAll and getAll of its own, which is one reason to stay on the path. Mind the argument order, which differs by home: a path's methods take the source last, path.modify(f, source); Traversals.modify(traversal, f, source) puts the optic first; and OpticOps, on the next page, puts the source first.


When you need the optic, not the path

Stay on the path by default. It has every read and write, its field names locate a validation error, and it can hand its value on to an Effect Path. Take the optic out when one of these is true:

WhenWhat to do
An API takes an optic: OpticOps, which Updates That Can Fail uses, or hkj-test's LensLaws, which checks that a hand-written lens behavesHand it path.toLens(), toAffine() or toTraversal()
The processor cannot reach the type, because you cannot annotate the classWrite the lens with Lens.of, as in What a lens is, and check it with LensLaws; or generate the optics with @ImportOptics, which Optics for External Types covers, and when to write a lens by hand compares
The optic has no path type: an Iso, a Getter, a Fold or a SetterUse it directly. An iso still joins a path through .via(iso), and every path gives you its fold with asFold()
A compiler message names an optic type, such as Affine<Order, Card>Read it as the optic inside the matching path type; the grid says what that optic can do, and Compiler Errors lists the common messages

Key Takeaways

  • A path is an optic with a friendlier API. FocusPath wraps a Lens, AffinePath an Affine and TraversalPath a Traversal; toLens(), toAffine() and toTraversal() take it out.
  • A lens is an accessor and a copy. Lens.of(getter, setter) is the whole definition, and the processor writes one per record component.
  • andThen is .via() for optics. The result reaches as many values as both steps allow together, an Iso takes the type of what follows it, and Composition Rules tabulates every pair.
  • Two questions pick the optic. How many values it reaches, and whether it may write.
  • Stay on the path until an API, a type or a message needs the optic.

Hands-On Learning

See Also


Previous: Collections, Optionals and Sealed Types Next: Updates That Can Fail