Optic Composition Rules

The optic type andThen returns for every pair of optics, and why.


Ranking by Capability

Optics order themselves by capability, from most specific (most operations available) to most general (fewest). An arrow points from an optic to one that can do less:

flowchart TD
    I(["Iso"]) --> L(["Lens"])
    I --> P(["Prism"])
    L --> G(["Getter"])
    L --> F(["Fold"])
    L --> A(["Affine"])
    P --> A
    A --> F
    A --> T(["Traversal"])
    P --> T
    T --> F
    T --> St(["Setter"])

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    class I,L,P,G,F,A,T,St tier

Capability, not Java subtyping

These arrows rank what each optic can do; they are not extends edges. Getter extends Fold is the only inheritance between two optic types. Some steps are explicit conversions such as asFold() or asTraversal(); others, such as Lens to Affine, are reached only by composing. Conversions lists the ones that exist, and Optic Capabilities has the per-method table.

What is Affine? An Affine optic focuses on zero or one element within a structure. It combines the partial access of a Prism with the update capability of a Lens. Common use cases include:

  • Accessing Optional<T> fields in records
  • Working with nullable properties
  • Navigating through optional intermediate structures

Key insight: composing two optics gives the most capable optic that both steps can support; the composition rules table states the rule exactly.


Composition Rules Table

Read each cell as what first.andThen(second) returns, with the row as first. A test in the examples module reads this table from the andThen overloads themselves, so it cannot promise a composition the library does not have:

first.andThen(second)IsoLensPrismAffineTraversal
IsoIsoLensPrismAffineTraversal
LensLensLensAffineAffineTraversal
PrismPrismAffinePrismAffineTraversal
AffineAffineAffineAffineAffineTraversal
TraversalTraversalTraversalTraversalTraversalTraversal

Each optic is fixed by two answers: how many values it reaches, and whether it can build the whole from its part.

ReachesCan build the wholeCannot
exactly oneIsoLens
zero or onePrismAffine
zero or moreTraversal

A composition reaches the wider of its two steps' reaches, and can build only if both steps can. So a Lens and a Prism reach zero or one value, and the lens cannot build: an Affine. Two prisms reach zero or one and both build: a Prism.

Fold, Getter and Setter compose with their own kind (fold.andThen(otherFold)), and with the others after a conversion such as asFold(); Conversions lists them.


Why Lens >>> Prism = Affine

This is perhaps the most important composition rule to understand.

The Intuition

A Lens guarantees exactly one focus. A Prism provides zero-or-one focuses (it may not match).

When you compose them:

  • The Lens always gets you to A
  • The Prism may or may not get you from A to B

Result: zero-or-one focuses, which is an Affine optic.

Example

// Domain model
record Config(Optional<DatabaseSettings> database) {}
record DatabaseSettings(String host, int port) {}

// The Lens always gets the Optional<DatabaseSettings>
Lens<Config, Optional<DatabaseSettings>> databaseLens =
    Lens.of(Config::database, (c, db) -> new Config(db));

// The Prism may or may not extract the DatabaseSettings
Prism<Optional<DatabaseSettings>, DatabaseSettings> somePrism = Prisms.some();

// Composition: Lens >>> Prism = Affine
Affine<Config, DatabaseSettings> databaseAffine =
    databaseLens.andThen(somePrism);

// Usage
Config config1 = new Config(Optional.of(new DatabaseSettings("localhost", 5432)));
Optional<DatabaseSettings> result1 = databaseAffine.getOptional(config1);
// result1 = Optional[DatabaseSettings[host=localhost, port=5432]]

Config config2 = new Config(Optional.empty());
Optional<DatabaseSettings> result2 = databaseAffine.getOptional(config2);
// result2 = Optional.empty() (the prism didn't match)

// Setting always succeeds
Config updated = databaseAffine.set(new DatabaseSettings("newhost", 3306), config2);
// updated = Config[database=Optional[DatabaseSettings[host=newhost, port=3306]]]

Why Prism >>> Lens = Affine

Similarly, composing a Prism first and then a Lens also yields an Affine.

The Intuition

A Prism may or may not match. If it matches, the Lens always gets you to the field.

Result: zero-or-one focuses, depending on whether the Prism matched.

Example

// Domain model with sealed interface
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius, String colour) implements Shape {}
record Rectangle(double width, double height, String colour) implements Shape {}

// The Prism may or may not match Circle
Prism<Shape, Circle> circlePrism = Prism.of(
    shape -> shape instanceof Circle c ? Optional.of(c) : Optional.empty(),
    c -> c
);

// The Lens always gets the radius from a Circle
Lens<Circle, Double> radiusLens =
    Lens.of(Circle::radius, (c, r) -> new Circle(r, c.colour()));

// Composition: Prism >>> Lens = Affine
Affine<Shape, Double> circleRadiusAffine = circlePrism.andThen(radiusLens);

// Usage
Shape circle = new Circle(5.0, "red");
Optional<Double> radius = circleRadiusAffine.getOptional(circle);
// radius = Optional[5.0]

Shape rectangle = new Rectangle(10.0, 20.0, "blue");
Optional<Double> empty = circleRadiusAffine.getOptional(rectangle);
// empty = Optional.empty() (prism didn't match)

// Modification only affects circles
Shape modified = circleRadiusAffine.modify(r -> r * 2, circle);
// modified = Circle[radius=10.0, colour=red]

Shape unchanged = circleRadiusAffine.modify(r -> r * 2, rectangle);
// unchanged = Rectangle[width=10.0, height=20.0, colour=blue] (unchanged)

Available Composition Methods

higher-kinded-j provides direct andThen methods that automatically return the correct type:

// Lens >>> Lens = Lens
Lens<A, C> result = lensAB.andThen(lensBC);

// Lens >>> Prism = Affine
Affine<A, C> result = lensAB.andThen(prismBC);

// Prism >>> Prism = Prism
Prism<A, C> result = prismAB.andThen(prismBC);

// Prism >>> Lens = Affine
Affine<A, C> result = prismAB.andThen(lensBC);

// Affine >>> Affine = Affine
Affine<A, C> result = affineAB.andThen(affineBC);

// Affine >>> Lens = Affine
Affine<A, C> result = affineAB.andThen(lensBC);

// Traversal >>> Traversal = Traversal
Traversal<A, C> result = traversalAB.andThen(traversalBC);

Via asTraversal (Universal Fallback)

Every pair of the five optics composes directly, so you rarely need this. Convert to Traversal when you want to hold optics of different kinds as one type, such as in a list of paths:

// Any optic composition via Traversal
Traversal<A, D> result =
    optic1.asTraversal()
        .andThen(optic2.asTraversal())
        .andThen(optic3.asTraversal());

It loses type information: you get a Traversal even where a more specific optic was possible. Fold and Getter do not convert to a Traversal at all.


Practical Guidelines

1. Use Direct Composition When Possible

// Preferred: direct andThen keeps the precise type, here zero or one host
Affine<Config, String> host =
    databaseLens.andThen(somePrism).andThen(hostLens);

2. Chain Multiple Compositions

// Multiple compositions
Affine<Order, String> customerEmail =
    orderCustomerLens                    // Lens<Order, Customer>
        .andThen(customerContactPrism)   // Prism<Customer, ContactInfo>
        .andThen(contactEmailLens);      // Lens<ContactInfo, String>

3. Store Complex Compositions as Constants

public final class OrderOptics {
    // Reusable compositions
    public static final Affine<Order, String> CUSTOMER_EMAIL =
        OrderLenses.customer()
            .andThen(CustomerPrisms.activeCustomer())
            .andThen(ActiveCustomerLenses.email());

    public static final Traversal<Order, Money> LINE_ITEM_PRICES =
        OrderTraversals.lineItems()
            .andThen(LineItemLenses.price());
}

Parallel Composition with Fold.plus()

The composition rules describe sequential composition (andThen): navigating deeper into a structure. Higher-Kinded-J also supports parallel composition via Fold.plus(), which combines results from multiple paths at the same level.

OperationTypePurpose
andThenSequentialNavigate deeper: A -> B -> C
plusParallelCombine results: A -> B and A -> C into A -> (B + C)
// Sequential: navigate deeper into the structure
Fold<Customer, Item> items = ordersFold.andThen(itemsFold);

// Parallel: combine results from different paths
Fold<Person, String> allNames = firstNameFold.plus(lastNameFold);

// Both together: compose then combine
Fold<Team, String> allEmails = Fold.sum(
    leadLens.asFold().andThen(emailLens.asFold()),
    membersFold.andThen(emailLens.asFold())
);

plus produces a Fold regardless of the input optic types, since the combined result is always read-only. Convert other optics via asFold() before combining.


Common Patterns

Pattern 1: Optional Field Access

Navigate to an optional field that may not exist:

@GenerateLenses record User(String name, Optional<Address> address) {}
@GenerateLenses record Address(String street, String city) {}

// Lens to Optional, Prism to extract, Lens to field
Affine<User, String> userCity =
    UserLenses.address()           // Lens<User, Optional<Address>>
        .andThen(Prisms.some())    // Prism<Optional<Address>, Address>
        .andThen(AddressLenses.city()); // Lens<Address, String>

Pattern 2: Sum Type Field Access

Navigate into a specific case of a sealed interface:

@GeneratePrisms sealed interface Payment permits CreditCard, BankTransfer {}
@GenerateLenses record CreditCard(String number, String expiry) implements Payment {}
record BankTransfer(String iban, String bic) implements Payment {}

// Prism to case, Lens to field
Affine<Payment, String> creditCardNumber =
    PaymentPrisms.creditCard()     // Prism<Payment, CreditCard>
        .andThen(CreditCardLenses.number()); // Lens<CreditCard, String>

Pattern 3: Conditional Collection Access

Navigate into items that match a condition:

// Traversal over list, filter by predicate
Traversal<List<Order>, Order> activeOrders =
    Traversals.<Order>forList()
        .andThen(Traversals.filtered(Order::isActive));

Summary

CompositionResultUse Case
Lens >>> LensLensNested product types (records)
Lens >>> PrismAffineProduct containing sum type
Prism >>> LensAffineSum type containing product
Prism >>> PrismPrismNested sum types
Affine >>> AffineAffineChained optional access
Affine >>> LensAffineOptional then field access
Affine >>> PrismAffineOptional then variant match
Any >>> TraversalTraversalCollection access
Iso >>> AnySame as secondType conversion first

Why this matters

The result type of a composition is not a convenience, it is a promise. When Lens >>> Prism hands you an Affine, the type is telling you the focus can be absent, and the compiler will not let you forget it; when a chain stays a Lens, totality survived every step and no absence handling is needed. That is the same discipline the mapping chapter later formalises as truthful tiers: the API only ever offers what the composition can lawfully support, so a whole class of "worked in the demo, failed in production" bugs becomes unrepresentable.

See Also

  • Affines: the zero-or-one optic most compositions land on
  • Cheat Sheet: the whole optics API on one page

Hands-On Learning

Practise lens composition in Tutorial 02: Lens Composition (7 exercises).


Previous: Conversions Next: Focus DSL Reference