An Introduction to Optics
As Java developers, we love the safety and predictability of immutable objects, especially with the introduction of records. However, this safety comes at a cost: updating nested immutable data can be a verbose and error-prone nightmare.
Consider a simple nested record structure:
record Street(String name, int number) {}
record Address(Street street, String city) {}
record User(String name, Address address) {}e
How do you update the user's street name? In standard Java, you're forced into a "copy-and-update" cascade:
// The "classic" approach
User user = new User("Magnus", new Address(new Street("Main St", 123), "London"));
Street oldStreet = user.address().street();
Street newStreet = new Street("Broadway", oldStreet.number()); // Create new street
Address oldAddress = user.address();
Address newAddress = new Address(newStreet, oldAddress.city()); // Create new address
User updatedUser = new User(user.name(), newAddress); // Create new user
This is tedious, hard to read, and gets exponentially worse with deeper nesting. What if there was a way to "zoom in" on the data you want to change, update it, and get a new copy of the top-level object back, all in one clean operation?
This is the problem that Optics solve.
What Are Optics?
At their core, optics are simply composable, functional getters and setters for immutable data structures.
Think of an optic as a "zoom lens" for your data. It's a first-class object that represents a path from a whole structure (like User
) to a specific part (like the street name
). Because it's an object, you can pass it around, compose it with other optics, and use it to perform functional updates.
Every optic provides two basic capabilities:
get
: Focus on a structureS
and retrieve a partA
.set
: Focus on a structureS
, provide a new partA
, and receive a newS
with the part updated. This is always an immutable operation—a new copy ofS
is returned.
The real power comes from their composability. You can chain optics together to peer deeply into nested structures and perform targeted updates with ease.
The Optics Family in Higher-Kinded-J
The higher-kinded-j
library provides the foundation for a rich optics library, primarily focused on three main types. Each is designed to solve a specific kind of data access problem.
1. Lens: For "Has-A" Relationships 🔎
A Lens is the most common optic. It focuses on a single, required piece of data within a larger "product type" (a record
or class with fields). It's for data that is guaranteed to exist.
- Problem it solves: Getting and setting a field within an object, especially a deeply nested one.
- Example: To solve our initial problem of updating the user's street name:
- Before (The "Copy Cascade")
// Manually rebuilding the object tree
User user = ...
Address newAddress = new Address(new Street("New Street", user.address().street().number()), user.address().city());
User updatedUser = new User(user.name(), newAddress);
- After (Composed Lenses): With an annotation processor,
higher-kinded-j
generates lenses for you. You compose them to create a direct path to the nested data.
// Composing lenses to create a "shortcut"
var userAddressLens = UserLenses.address();
var addressStreetLens = AddressLenses.street();
var streetNameLens = StreetLenses.name();
var userToStreetName = userAddressLens.andThen(addressStreetLens).andThen(streetNameLens);
// Perform the deep update in a single, readable line
User updatedUser = userToStreetName.set("New Street", user);
2. Iso: For "Is-Equivalent-To" Relationships 🔄
An Iso (Isomorphism) is a special, reversible optic. It represents a lossless, two-way conversion between two types that hold the exact same information. Think of it as a type-safe, composable adapter.
-
Problem it solves: Swapping between different representations of the same data, such as a wrapper class and its raw value, or between two structurally different but informationally equivalent records.
-
Example: Suppose you have a
Point
record and aTuple2<Integer, Integer>
, which are structurally different but hold the same data. Javapublic record Point(int x, int y) {}
You can define an
Iso
to convert between them:@GenerateIsos public static Iso<Point, Tuple2<Integer, Integer>> pointToTuple() { return Iso.of( point -> Tuple.of(point.x(), point.y()), // get tuple -> new Point(tuple._1(), tuple._2()) // reverseGet ); }
This
Iso
can now be composed with other optics to, for example, create aLens
that goes from aPoint
directly to its first element inside aTuple
representation.
3. Prism: For "Is-A" Relationships 🔬
A Prism is like a Lens, but for "sum types" (sealed interface
or enum
). It focuses on a single, possible case of a type. A Prism's get
operation can fail (it returns an Optional
), because the data might not be the case you're looking for. Think of it as a type-safe, functional instanceof
and cast.
- Problem it solves: Safely operating on one variant of a sealed interface.
- Example: Instead of using an
if-instanceof
chain to handle a specificDomainError
:-
Before (Manual instanceof check and cast):
if (error instanceof DomainError.ShippingError se && se.isRecoverable()) { // ... handle recoverable error }
-
After (Using a generated Prism): Annotating the sealed interface (
@GeneratePrisms
) generates ashippingError()
prism, which you can use in a clean, functional pipeline:DomainErrorPrisms.shippingError() .getOptional(error) // Safely gets a ShippingError if it matches .filter(ShippingError::isRecoverable) .ifPresent(this::handleRecovery); // Perform action only if it's the right type
-
4. Traversal: For "Has-Many" Relationships 🗺️
A Traversal is an optic that can focus on multiple targets at once—typically all the items within a collection inside a larger structure.
-
Problem it solves: Applying an operation to every element in a
List
,Set
, or other collection that is a field within an object. -
Example: To validate a list of promo codes in an order with
Validated
: Java@GenerateTraversals public record OrderData(..., List<String> promoCodes) {}
The generated
Traversal<OrderData, String>
forpromoCodes
can be used to apply a validation function to every code and get a single result back (either a list of valid codes or the first error).var codesTraversal = OrderDataTraversals.promoCodes(); var validationFunction = (String code) -> validate(code); // returns Validated<Error, Code> // Use the traversal to apply the function to every code. // The Applicative for Validated handles the "fail-fast" logic automatically. Validated<Error, OrderData> result = codesTraversal.modifyF( validationFunction, orderData, validatedApplicative );
This powerful operation is made seamless because the
Traversal
optic leverages aTraverse
typeclass instance, which provides the underlying "engine" for iterating over theList
.
How higher-kinded-j
Provides Optics
This brings us to the unique advantages higher-kinded-j
offers for optics in Java.
- An Annotation-Driven Workflow: Manually writing optics is boilerplate. The
higher-kinded-j
approach automates this. By simply adding an annotation (@GenerateLenses
,@GeneratePrisms
, etc.) to your data classes, you get fully-functional, type-safe optics for free. This is a massive productivity boost and eliminates a major barrier to using optics in Java. - Higher-Kinded Types for Effectful Updates: This is the most powerful feature. Because
higher-kinded-j
provides an HKT abstraction (Kind<F, A>
) and typeclasses likeFunctor
andApplicative
, the optics can perform effectful modifications. ThemodifyF
method is generic over anApplicative
effectF
. This means you can perform an update within the context of any data type that has anApplicative
instance:- Want to perform an update that might fail? Use
Optional
orEither
as yourF
. - Want to perform an asynchronous update? Use
CompletableFuture
as yourF
. - Want to accumulate validation errors? Use
Validated
as yourF
.
- Want to perform an update that might fail? Use
This level of abstraction allows you to write highly reusable and testable business logic that is completely decoupled from the details of state management, asynchrony, or error handling—a core benefit of functional programming brought to Java by the foundation higher-kinded-j
provides.