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.

What You'll Learn

  • Generate optics for a JDK or library type with @ImportOptics on a package-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 withX method 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.

Why this matters

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.

The pairing rule, exactly

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 themAn 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.


Quick Reference

// 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

  1. Can you annotate the type? It is your code: use @GenerateLenses and friends directly.
  2. Is it a record, sealed type, enum, or a wither class whose getters line up? @ImportOptics, and you are done.
  3. Does it use builders, predicates, or non-standard naming? Write a spec interface and declare the optics you want.
  4. Does it already implement List, Map or Optional? 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.


Key Takeaways

  • @ImportOptics brings types you do not own into the same optic algebra as your own. The generated optics compose with andThen exactly 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 exactly T, which is why LocalDate has 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.

See Also

Further Reading


Previous: Optics Extensions Next: Taming JSON with Jackson