Optics for External Types
Generate optics for a JDK or library type you cannot annotate, and compose them with your own.
"The real voyage of discovery consists not in seeking new landscapes, but in having new eyes."
– Marcel Proust
The landscape (JDK classes, database libraries, JSON parsers) already exists. What changes is how we see it. With @ImportOptics we gain new eyes: LocalDate, JsonNode or any external type becomes a participant in compositional, type-safe transformations. We are not adding code to those libraries; we are generating a view onto them.
- Generate optics for a JDK or library type with
@ImportOpticson apackage-info.java - Predict what auto-detection generates for a record, a sealed type, an enum, a wither class and a container field
- Check the pairing rule that decides whether a
withXmethod gets a lens - Compose an imported optic with your own, such as the year an order settled
- Decide when to write a spec interface instead, for builders, predicates or non-standard naming
The Frustration
Optics work beautifully across your own records. Then you hit a type you do not own:
// An order's settlement: our own record, dated with the JDK's LocalDate
@GenerateLenses
record Settlement(UUID orderId, LocalDate settledOn, BigDecimal amount) {}
// How do we bump just the year inside settledOn?
// LocalDate lives in java.time. We cannot annotate it.
This is what @ImportOptics solves.
The Quick Win
Put the annotation on a package-info.java in your optics package:
@ImportOptics(java.time.LocalDate.class)
package com.myapp.optics;
import org.higherkindedj.optics.annotations.ImportOptics;
The processor analyses LocalDate, finds the wither methods it can pair with a getter (getX() reads, withX(value) returns a modified copy), and generates LocalDateLenses. Now external and local optics compose as equals:
// settledOn() is ours (@GenerateLenses); year() is the JDK's (@ImportOptics)
Settlement nextYear =
SettlementLenses.settledOn().andThen(LocalDateLenses.year()).modify(y -> y + 1, settlement);
// the same settlement, dated a year later
int year = SettlementLenses.settledOn().andThen(LocalDateLenses.year()).get(settlement);
One annotation, and a JDK type joins the pipeline.
Nothing here is reflective and nothing is a string. LocalDateLenses.year() is a generated Lens<LocalDate, Integer> built from getYear() and withYear(int), so it composes with your own optics, obeys the lens laws, and fails at compile time if the library changes under you. The alternative, hand-writing Lens.of(LocalDate::getYear, LocalDate::withYear) for every field of every external type, is the same code you would have written, minus the typing.
How Auto-Detection Works
The processor looks at each imported type and picks a strategy from its shape:
flowchart LR
accTitle: How auto-detection picks a strategy
accDescr: A record gets lenses through its canonical constructor, a sealed interface one prism per permitted subtype, an enum one prism per constant, and a class with withX methods lenses through getter and wither. Any other type is not supported, and needs a spec interface.
T{"The imported<br/>type is..."}
T -->|"a record"| R(["Lenses, via the<br/>canonical constructor"])
T -->|"a sealed interface"| S@{ shape: hex, label: "Prisms, one per<br/>permitted subtype" }
T -->|"an enum"| E@{ shape: hex, label: "Prisms, one per<br/>constant" }
T -->|"a class with<br/>withX methods"| W(["Lenses, via<br/>getter and wither"])
T -->|"anything else"| X["Not supported:<br/>write a spec interface"]
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
classDef rw fill:#a6d189,stroke:#40a02b,color:#232634
classDef error fill:#e78284,stroke:#d20f39,color:#232634
class T decision
class R,S,E,W rw
class X error
In words: records and classes with withers get lenses, sealed interfaces and enums get prisms, and any other shape needs a spec interface.
Records to Lenses
// The external library has:
public record Coordinate(double lat, double lon) {}
// You get:
Lens<Coordinate, Double> lat = CoordinateLenses.lat();
Lens<Coordinate, Double> lon = CoordinateLenses.lon();
Records are the easy case: the canonical constructor is the copy mechanism.
Sealed Types to Prisms
// From a payment gateway's SDK, which we cannot annotate:
public sealed interface PaymentInstrument permits CardInstrument, BankAccount, CryptoWallet {}
Prism<PaymentInstrument, CardInstrument> card = PaymentInstrumentPrisms.cardInstrument();
Prism<PaymentInstrument, BankAccount> bankAccount = PaymentInstrumentPrisms.bankAccount();
Prism<PaymentInstrument, CryptoWallet> wallet = PaymentInstrumentPrisms.cryptoWallet();
These are the prisms @GeneratePrisms writes, so a permitted subtype that is an inner class of a generic class is named as Generating the Prisms describes.
Enums to Prisms
// From a courier's SDK, which we cannot annotate:
public enum TrackingStatus { PENDING, IN_TRANSIT, DELIVERED, RETURNED }
Prism<TrackingStatus, TrackingStatus> pending = TrackingStatusPrisms.pending();
Prism<TrackingStatus, TrackingStatus> inTransit = TrackingStatusPrisms.inTransit(); // and so on, one per constant
Each prism takes the constant's camelCase name, and a name that would be a Java keyword or literal takes a trailing underscore, as Prisms explains.
Wither Classes to Lenses
Immutable JDK types and many library types follow the wither pattern: getX() reads, withX(value) returns a modified copy.
A lens is generated for withXxx(T) only when the type also has a public no-arg method named xxx(), getXxx() or isXxx() returning exactly T, and withXxx itself returns the type under its own type arguments, or a subtype of it. That is stricter than it looks. LocalDate gets year(), dayOfMonth() and dayOfYear(), but not a month lens: withMonth takes an int, while getMonth() returns Month, so the pair does not typecheck and the field is skipped. A wither on a Tagged<T> that returns a raw Tagged, or a Tagged<String>, is skipped the same way, since the lens hands its result back as a Tagged<T>; a retag, <U> Tagged<U> withId(String), pairs, because the call infers U back to T.
The lens is named after what follows with, and how that is spelt decides what pairs:
| The class declares | @ImportOptics generates |
|---|---|
withdraw(long) beside getDraw() | No draw lens. A lower-case letter straight after with makes a word, not a wither |
witheMail(String) beside geteMail(), as Lombok's beanspec capitalisation writes them | An eMail() lens, as withEMail(String) beside getEMail() gives. A capital after the lower-case letter makes it a wither, and its getter may be spelt the same way |
withEMail(String) beside only geteMail() | No lens. A geteMail() pairs only with a wither spelt the same way, witheMail |
withURL(String) beside getURL() | A uRL() lens: the name is what follows with, its first letter in lower case. To name it url(), declare it in a spec interface with @Wither(value = "withURL", getter = "getURL") |
with2D(int) beside get2D() | No lens: it would be named 2D(), and no method's name can start with a digit |
withN(int) beside n(), and withN(String) beside getN() | One n() lens, through withN(int). The rule prefers n(), then getN(), then isN(), and a note reports the other wither |
withEMail(String) and witheMail(String), both beside eMail() | One eMail() lens, through withEMail, whichever the class declares first. A note reports the other wither |
The generated lens calls the wither that paired, even where an overload of its name would take the boxed value the lens holds: a withN(int) beside a withN(Integer) or a withN(Object) is called as source.withN((int) newValue), so the boxed overload does not take the call. When a wither you expected is missing from the generated class, the pairing rule is almost always why. Where the getter is the mismatch, reach for a spec interface and name the getter yourself. Where the return is, a spec over the type the wither does return, OpticsSpec<Tagged<String>>, can use it; a raw return cannot be used by either route.
An inner class of a generic class is imported under its enclosing class's type parameters, since that is how the type has to be written once the enclosing class takes arguments: @ImportOptics({Outer.Line.class}) on a class Outer<X> gives <X> Lens<Outer<X>.Line, String>.
Container Fields Get Traversals
When an imported record has a collection field, you get both a lens to the collection and a traversal into its elements, named <field>Traversal:
// External:
public record Department(String name, List<Employee> staff) {}
// Generated:
Lens<Department, String> name = DepartmentLenses.name();
Lens<Department, List<Employee>> staff = DepartmentLenses.staff();
Traversal<Department, Employee> eachMember = DepartmentLenses.staffTraversal();
An element type written as a wildcard is focused as the type it stands for, as @GenerateTraversals does: List<? extends Employee> gives a Traversal<Department, Employee>, and List<?> a Traversal<Department, Object>. See Wildcard Element Types.
A Real Workflow: Fiscal Year Normalisation
Composing across the boundary reads the same as composing within it:
// The year of the settlement date, as one optic
Lens<Settlement, Integer> settlementYear =
SettlementLenses.settledOn().andThen(LocalDateLenses.year());
Settlement normalised = settlementYear.set(2027, settlement);
boolean inFiscalYear = settlementYear.get(settlement) == 2026;
// The generated wither helpers are there too, when a lens is more than you need
LocalDate monthStart = LocalDateLenses.withDayOfMonth(settlement.settledOn(), 1);
settledOn().andThen(year()) reads as English: the year of the settlement date. Local and external optics are the same kind of value.
When Auto-Detection Is Not Enough
Some types resist it:
Builder patterns. No withers, no all-args constructor. JOOQ POJOs, Lombok @Builder, Immutables, AutoValue, Protobuf messages all copy through a builder, and there is no naming convention the processor can assume.
Non-standard naming. config.derivedWith(newValue) rather than withX, a getter whose return type does not match the wither parameter (the LocalDate.getMonth() case above), or a wither that returns the type under other arguments.
Predicate-based type discrimination. Jackson's JsonNode uses isObject() and isArray() rather than a sealed hierarchy, so there is nothing to enumerate.
For these, declare what you want explicitly with a spec interface: an interface extending OpticsSpec<S> whose methods carry annotations telling the processor how to build each optic.
// Simple import: auto-detection handles the rest
@ImportOptics({
java.time.LocalDate.class,
java.time.LocalTime.class,
com.library.SimpleRecord.class
})
package com.myapp.optics;
// The options, when you need them
@ImportOptics(
value = {MutableConfig.class},
allowMutable = true, // acknowledge the lens-law limitation
targetPackage = "com.myapp.generated")
@ImportOptics goes on a package-info.java or on a type declaration; both generate into the annotated element's package unless targetPackage says otherwise.
Choosing an Approach for a New Library
- Can you annotate the type? It is your code: use
@GenerateLensesand friends directly. - Is it a record, sealed type, enum, or a wither class whose getters line up?
@ImportOptics, and you are done. - Does it use builders, predicates, or non-standard naming? Write a spec interface and declare the optics you want.
- Does it already implement
List,MaporOptional? You may need nothing at all: the standard traversals work on it directly.
A type another annotation processor writes in the same build works like any other. List it, or name it as a spec's source type, and @ImportOptics waits until that processor has written it. If it never appears, javac reports the missing type, and code calling the generated class reports that class missing too.
@ImportOpticsbrings types you do not own into the same optic algebra as your own. The generated optics compose withandThenexactly like the ones generated from your own records.- Four shapes are auto-detected: records and wither classes give lenses, sealed types and enums give prisms.
- The wither rule is strict about types.
withX(T)needs a getter returning exactlyT, which is whyLocalDatehas no month lens, and has to return the class under its own type arguments, a subtype of it, or a retag the call infers back to it. - Collection fields get a traversal too, named
<field>Traversal. - Builders and predicate-based types need a spec interface, which is the subject of the next two pages.
- Taming JSON with Jackson: spec interfaces,
@InstanceOfand@MatchWhen, worked onJsonNode - Database Records with JOOQ:
@ViaBuilderand the other copy strategies - Focus DSL with External Libraries: bridging Focus navigation into generated external optics
- Oracle: java.time API:
LocalDate,LocalTime,Instantand friends, the canonical wither-pattern types - Immutables: immutables.github.io: value objects with generated builders and withers
- AutoValue: github.com/google/auto: google's immutable value types
Previous: Optics Extensions Next: Taming JSON with Jackson