When Lenses Assume Too Much
Update two fields a constructor checks together, such as a range's bounds, in one step.
"A bug is never just a mistake. It represents something bigger. An error of thinking that makes you who you are."
– Elliot Alderson, Mr. Robot
When a lens update throws an exception, it is not the lens that is broken; it is our assumption about field independence. What looks like a bug often reveals a deeper truth about the relationship between fields in our data structures.
Consider a familiar scenario: you have a record with validation in its constructor. You have written (or generated) lenses for each field. Everything works perfectly, until you need to update two fields together. Suddenly, valid transformations become impossible.
The hidden culprit? Standard lens composition assumes fields are independent, that you can update lo without caring about hi. But some fields are coupled by invariants. They do not just coexist; they constrain each other. Lenses, in their elegant simplicity, do not know this.
- Predict when two lens writes in a row fail on a record whose constructor checks two fields together
- Update coupled fields in one step with
Lens.paired, keeping the record's other components with theFunction3form - Update three to nine coupled fields the same way with
CoupledLenses.coupled3tocoupled9 - Decide when a field needs a paired lens, a domain method, or no lens at all
The Independence Assumption
When we compose lenses with andThen, we are drilling vertically through nested structures:
flowchart TD
accTitle: Composing lenses vertically and horizontally
accDescr: Vertically, andThen joins a Lens from S to A with a Lens from A to B, giving a Lens from S to B that reaches one nested field. Horizontally, paired joins a Lens from S to A and a Lens from S to B, giving a Lens from S to a Pair of A and B that reaches two sibling fields.
%% declared in reverse: the layout places the last subgraph on the left
subgraph horiz["Horizontal: paired, two fields at once"]
direction TB
H1(["Lens<S, A>"]) --> H3(["Lens<S, Pair<A, B>><br/>two sibling fields"])
H2(["Lens<S, B>"]) --> H3
end
subgraph vert["Vertical: andThen, drilling deeper"]
direction TB
V1(["Lens<S, A>"]) -->|"andThen"| V2(["Lens<A, B>"]) --> V3(["Lens<S, B><br/>one nested field"])
end
classDef rw fill:#a6d189,stroke:#40a02b,color:#232634
class V1,V2,V3,H1,H2,H3 rw
In words: andThen chains two lenses to reach one nested field, and paired puts two lenses side by side to reach two sibling fields at once.
Vertical composition (andThen) assumes that once you have focused on a field, you can update it independently. This works beautifully for nested structures like Employee → Company → Address → Street.
But what about sibling fields at the same level that share an invariant?
The Problem: Invariant Violation
Consider a simple bounded range:
record Range(int lo, int hi) {
Range {
if (lo > hi) {
throw new IllegalArgumentException(
"lo (" + lo + ") must be <= hi (" + hi + ")");
}
}
}
We can create lenses for each field:
Lens<Range, Integer> loLens =
Lens.of(Range::lo, (r, lo) -> new Range(lo, r.hi()));
Lens<Range, Integer> hiLens =
Lens.of(Range::hi, (r, hi) -> new Range(r.lo(), hi));
Now let us try to shift the range up by 10:
Range range = new Range(1, 2);
// Goal: Range(1, 2) → Range(11, 12)
// Attempt 1: update lo first, which asks for Range(11, 2)
Range step1 = loLens.set(11, range);
// THROWS: "lo (11) must be <= hi (2)"
The update failed because the intermediate state Range(11, 2) violates the invariant.
What if we update hi first?
Range step1 = hiLens.set(12, range); // Range(1, 12) - OK!
Range step2 = loLens.set(11, step1); // Range(11, 12) - OK!
That works! But now try shifting down by 10 from Range(10, 11):
Range narrow = new Range(10, 11);
// If we update hi first: Range(10, 1) - THROWS!
// If we update lo first: Range(0, 11) → Range(0, 1) - OK
Range shiftedDown = hiLens.set(1, loLens.set(0, narrow));
The "correct" order depends on the direction of change!
Why This Happens
flowchart TD
accTitle: Sequential and paired updates of a Range
accDescr: Sequentially, loLens.set(11) turns Range(1, 2) into Range(11, 2), which breaks the invariant because 11 is greater than 2. Paired, boundsLens.get reads Range(1, 2) as Pair(1, 2), the transform gives Pair(11, 12), and Range::new builds Range(11, 12) with the invariant held.
%% declared in reverse: the layout places the last subgraph on the left
subgraph pair["Paired: both bounds together"]
direction TB
P1["Range(1, 2)"] -->|"boundsLens.get"| P2["Pair(1, 2)"]
P2 -->|"transform"| P3["Pair(11, 12)"]
P3 -->|"Range::new"| P4["Range(11, 12)<br/>invariant held"]
end
subgraph seq["Sequential: one field at a time"]
direction TB
S1["Range(1, 2)"] -->|"loLens.set(11)"| S2["Range(11, 2)"] --> S3["invariant broken:<br/>11 > 2"]
end
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
classDef out fill:#a6d189,stroke:#40a02b,color:#232634
classDef bad fill:#e78284,stroke:#d20f39,color:#232634
class S1,S2,P1,P2,P3 tier
class P4 out
class S3 bad
The goal is to shift both bounds by ten, from Range(1, 2) to Range(11, 12), under the invariant lo <= hi.
Sequential lens updates create intermediate states. When fields are coupled by an invariant, these intermediate states can be invalid, even when both the starting and ending states are perfectly valid.
The Solution: Paired Lenses
"They're all tied in together."
– Sergeant Pinback, Dark Star
This boils down to: if fields are coupled, update them together. Lens.paired combines two lenses into one that focuses on both values as a Pair:
Lens<Range, Pair<Integer, Integer>> boundsLens =
Lens.paired(loLens, hiLens, Range::new);
Now we can shift safely:
Range range = new Range(1, 2);
// Shift up by 10 - both values updated atomically
Range shifted = boundsLens.modify(p -> Pair.of(p.first() + 10, p.second() + 10), range);
// Result: Range(11, 12)
The transformation happens in a single step:
- Extract both values:
Pair(1, 2) - Transform:
Pair(11, 12) - Reconstruct via
Range::new:Range(11, 12)
No intermediate state. No invariant violation. Order independence.
API Reference
Lens.paired with BiFunction
When your record has only the coupled fields:
static <S, A, B> Lens<S, Pair<A, B>> paired(
Lens<S, A> first,
Lens<S, B> second,
BiFunction<A, B, S> constructor
)
Example:
Lens<Range, Pair<Integer, Integer>> boundsLens =
Lens.paired(loLens, hiLens, Range::new);
Lens.paired with Function3
When your record has additional fields that must be preserved:
static <S, A, B> Lens<S, Pair<A, B>> paired(
Lens<S, A> first,
Lens<S, B> second,
Function3<S, A, B, S> reconstructor
)
Example:
record QuantityLimit(String id, int min, int max, String note) {
QuantityLimit {
if (min > max) throw new IllegalArgumentException("min > max");
}
}
Lens<QuantityLimit, Pair<Integer, Integer>> limitsLens = Lens.paired(
minLens,
maxLens,
(limit, newMin, newMax) -> new QuantityLimit(limit.id(), newMin, newMax, limit.note())
);
Choosing the Right Approach
| Scenario | Recommended Approach |
|---|---|
| Independent fields | Use individual lenses, or fold several into one operation with Edits |
| Fields with shared invariant | Use Lens.paired |
| Sparse or validated edits to fields a constructor checks together | Use Edits.accumulate(focus, …), with a Lens.paired as the focus if you have one |
| Computed/derived fields | Don't expose a lens for the computed field |
| Cross-structure invariants | Use domain methods, not lenses |
When NOT to Use Paired Lenses
Computed fields: If field B is always computed from field A, do not create a lens for B at all:
// Data is the source of truth; checksum is derived
Lens<Packet, byte[]> dataLens = Lens.of(
Packet::data,
(p, newData) -> new Packet(newData, computeChecksum(newData))
);
// No checksumLens - it's always recomputed
Cross-structure invariants: When invariants span parent and child objects, use domain methods. A Quotation, unlike the chapter's Order, stores the total its lines add up to:
// Don't use lenses - use domain operations
Quotation updated = quotation.withLine(
sku, line -> new LineItem(line.sku(), line.quantity(), newPrice));
// The withLine method recalculates the quotation's total internally
Practical Examples
Bounded Ranges
record Range(int lo, int hi) {
Range { if (lo > hi) throw new IllegalArgumentException(); }
}
Lens<Range, Pair<Integer, Integer>> boundsLens =
Lens.paired(loLens, hiLens, Range::new);
// Shift
Range shifted = boundsLens.modify(
p -> Pair.of(p.first() + 10, p.second() + 10),
range
);
// Scale
Range scaled = boundsLens.modify(
p -> Pair.of(p.first() * 2, p.second() * 2),
range
);
// Widen symmetrically
Range widened = boundsLens.modify(
p -> Pair.of(p.first() - 5, p.second() + 5),
range
);
Rectangles with Constraints
record Viewport(Point topLeft, Point bottomRight) {
Viewport {
if (topLeft.x() >= bottomRight.x() || topLeft.y() >= bottomRight.y()) {
throw new IllegalArgumentException("Invalid viewport bounds");
}
}
}
Lens<Viewport, Pair<Point, Point>> cornersLens =
Lens.paired(topLeftLens, bottomRightLens, Viewport::new);
// Move the entire viewport
Viewport moved = cornersLens.modify(
p -> Pair.of(
p.first().translate(dx, dy),
p.second().translate(dx, dy)
),
viewport
);
Configuration with Port Ranges
record ServerPorts(String host, int minPort, int maxPort) {
ServerPorts {
if (minPort > maxPort || minPort < 1024 || maxPort > 65535) {
throw new IllegalArgumentException("Invalid port range");
}
}
}
Lens<ServerPorts, Pair<Integer, Integer>> portsLens = Lens.paired(
minPortLens,
maxPortLens,
(ports, min, max) -> new ServerPorts(ports.host(), min, max)
);
// Shift port range up by 1000
ServerPorts updated = portsLens.modify(
p -> Pair.of(p.first() + 1000, p.second() + 1000),
ports
);
Composition with Paired Lenses
A paired lens is just a normal Lens<S, Pair<A, B>>, so it composes naturally:
// A Deployment holds ServerPorts, whose port range is coupled
Lens<Deployment, Pair<Integer, Integer>> deploymentPorts =
deploymentServerLens.andThen(serverPortsLens);
// Shift both ports atomically through the nested structure
Deployment updated = deploymentPorts.modify(
p -> Pair.of(p.first() + 1000, p.second() + 1000),
deployment
);
Avoid composing a paired lens with a lens that extracts a single element:
// DON'T DO THIS - defeats the purpose of pairing
Lens<Pair<Integer, Integer>, Integer> firstLens =
Lens.of(Pair::first, (p, a) -> Pair.of(a, p.second()));
Lens<Range, Integer> justLo = boundsLens.andThen(firstLens);
// You're back to the original problem!
If you need single-field access, use the original individual lens directly.
Three or More Coupled Fields
Lens.paired is the binary case. For three to nine coupled fields, reach for the arity ladder in CoupledLenses (in org.higherkindedj.optics.util), generated by hkj-processor:
import org.higherkindedj.optics.annotations.GenerateLenses;
import org.higherkindedj.optics.util.CoupledLenses;
@GenerateLenses
record Triple(int lo, int mid, int hi) {
Triple {
if (!(lo <= mid && mid <= hi)) {
throw new IllegalArgumentException("lo <= mid <= hi");
}
}
}
Lens<Triple, Integer> loLens = Lens.of(Triple::lo, (t, lo) -> new Triple(lo, t.mid(), t.hi()));
Lens<Triple, Integer> midLens = Lens.of(Triple::mid, (t, mid) -> new Triple(t.lo(), mid, t.hi()));
Lens<Triple, Integer> hiLens = Lens.of(Triple::hi, (t, hi) -> new Triple(t.lo(), t.mid(), hi));
// Same shape as Lens.paired, just one more lens and a Function3 constructor reference.
Lens<Triple, Tuple3<Integer, Integer, Integer>> bounds =
CoupledLenses.coupled3(loLens, midLens, hiLens, Triple::new);
Triple shifted = bounds.modify(
t -> new Tuple3<>(t._1() + 10, t._2() + 10, t._3() + 10),
new Triple(1, 5, 10));
// The constructor only ever sees the new triple, never a half-shifted one.
The ladder runs coupled3 through coupled9 and each method has two overloads, mirroring Lens.paired exactly:
| Form | Reconstructor signature | Use when |
|---|---|---|
| Preserving | (S, A, B, C, ...) -> S | the source has other fields the rebuild needs to keep |
| Simple | (A, B, C, ...) -> S (constructor reference) | the focused fields fully determine the source |
Both records here carry @GenerateLenses: the Triple already shown, and a Trade(String currency, BigDecimal amount, int precision) whose withMoney method sets all three.
// Preserving form: receives the original source so other fields can be carried over.
Lens<Trade, Tuple3<String, BigDecimal, Integer>> money =
CoupledLenses.coupled3(
TradeLenses.currency(), TradeLenses.amount(), TradeLenses.precision(),
(trade, ccy, amt, prec) -> trade.withMoney(ccy, amt, prec));
// Simple form: constructor reference when nothing else needs preserving.
Lens<Triple, Tuple3<Integer, Integer, Integer>> bounds =
CoupledLenses.coupled3(TripleLenses.lo(), TripleLenses.mid(), TripleLenses.hi(), Triple::new);
Lens.paired lives on the Lens interface in hkj-api. Generated code cannot add static methods to an existing API interface, so the arity ladder lives in hkj-core's org.higherkindedj.optics.util package as a separate utility class. One discoverability cost: you import CoupledLenses to use the ladder. Everything else is identical to Lens.paired, including the two-overload pattern, the atomic-reconstruction semantics, and the lens laws.
The ladder caps at coupled9 because cross-field invariants past five fields are already rare and past nine vanishing. If you genuinely need a higher arity (rather than splitting the record), open a GitHub feature request and the generator cap can be raised.
Lens Laws
Paired lenses satisfy the standard lens laws when the two lenses focus on different components and the constructor accepts the values you set:
- GetPut:
set(get(s), s) == s - PutGet:
get(set(a, s)) == a - PutPut:
set(a2, set(a1, s)) == set(a2, s)
These are verified by property-based tests in LensPairedLawsPropertyTest.java.
- Standard lenses assume field independence: updating one field should not affect another
- Coupled fields violate that assumption: an invariant makes two fields constrain each other
- Sequential updates pass through invalid intermediate states, even when the start and the end are both valid
Lens.pairedupdates the group atomically: no intermediate state exists for the constructor to rejectCoupledLenses.coupled3tocoupled9is the same shape for more fields, generated intoorg.higherkindedj.optics.util- Order independence follows: a paired lens does not care which direction you are transforming
Practise both the binary form and the arity ladder in Tutorial 23: N-ary Coupled Lenses (3 exercises).
- Lenses: core lens concepts and operations
- Composition Rules: how different optics compose
- Isomorphisms: transforming between constrained and unconstrained representations
Chris Penner: Virtual Record Fields Using Lenses: introduces "virtual fields" as computed properties accessed through lenses. Penner demonstrates how hiding data constructors and exporting only lenses creates a stable public interface that absorbs internal refactoring. His treatment of data invariants is relevant here: where we use Lens.paired to enforce invariants during updates, Penner uses lenses to hide representation details and maintain invariants transparently. He also candidly notes that breaking lens laws is "usually perfectly fine" for pragmatism, echoing our observation that real-world records often have constraints that do not fit the idealised lens model.
Gunnar Morling: Enforcing Java Record Invariants With Bean Validation: tackles record invariants from a different angle, using Bean Validation annotations to enforce constraints automatically at construction time. The article explicitly discusses multi-field invariants like "end must be greater than begin", precisely the kind of coupled constraint that breaks sequential lens updates. Morling's approach guarantees the invariant holds but does not help you transform a valid object when both fields must change together. This is where Lens.paired complements Bean Validation: validation solves the construction problem; paired lenses solve the transformation problem. In a robust system, you would use both.