Optics Cookbook
Practical Recipes for Common Problems
- Ready-to-use patterns for common optics scenarios
- Copy-paste recipes with explanations
- Best practices for production code
This cookbook provides practical recipes for common optics problems. Each recipe includes the problem statement, solution, and explanation.
Recipe 1: Updating Nested Optional Fields
Problem
You have a deeply nested structure with optional fields and need to update a value that may or may not exist.
Solution
record User(String name, Optional<Profile> profile) {}
record Profile(String bio, Optional<Settings> settings) {}
record Settings(boolean darkMode, int fontSize) {}
// Build the traversal path
Traversal<User, Integer> userFontSize =
UserLenses.profile() // Lens<User, Optional<Profile>>
.andThen(Prisms.some()) // Prism<Optional<Profile>, Profile>
.andThen(ProfileLenses.settings().asTraversal()) // Lens<Profile, Optional<Settings>>
.andThen(Prisms.some().asTraversal()) // Prism<Optional<Settings>, Settings>
.andThen(SettingsLenses.fontSize().asTraversal()); // Lens<Settings, Integer>
// Usage
User user = new User("Alice", Optional.of(
new Profile("Developer", Optional.of(new Settings(true, 14)))
));
// Increase font size if it exists, otherwise leave unchanged
User updated = Traversals.modify(userFontSize, size -> size + 2, user);
Why It Works
Each Prisms.some() safely handles the Optional - if any Optional is empty, the modification is skipped and the original structure is returned unchanged.
Recipe 2: Modifying a Specific Variant of a Sum Type
Problem
You have a sealed interface and want to modify only one specific variant whilst leaving others unchanged.
Solution
sealed interface ApiResponse permits Success, Error, Loading {}
record Success(Data data, String timestamp) implements ApiResponse {}
record Error(String message, int code) implements ApiResponse {}
record Loading(int progress) implements ApiResponse {}
// Create prism for the Success case
Prism<ApiResponse, Success> successPrism = Prism.of(
resp -> resp instanceof Success s ? Optional.of(s) : Optional.empty(),
s -> s
);
// Compose with lens to access data
Traversal<ApiResponse, Data> successData =
successPrism.andThen(SuccessLenses.data());
// Usage: transform data only for Success responses
ApiResponse response = new Success(new Data("original"), "2024-01-01");
ApiResponse modified = Traversals.modify(
successData,
data -> new Data(data.value().toUpperCase()),
response
);
// Result: Success[data=Data[value=ORIGINAL], timestamp=2024-01-01]
// Error responses pass through unchanged
ApiResponse error = new Error("Not found", 404);
ApiResponse unchanged = Traversals.modify(
successData,
data -> new Data(data.value().toUpperCase()),
error
);
// Result: Error[message=Not found, code=404] (unchanged)
Recipe 3: Bulk Updates Across Collections
Problem
You need to update all items in a collection that match certain criteria.
Solution
record Order(String id, List<LineItem> items) {}
record LineItem(String productId, int quantity, Money price) {}
// Traversal to all line items
Traversal<Order, LineItem> allItems =
OrderLenses.items().asTraversal()
.andThen(Traversals.forList());
// Traversal to high-quantity items only
Traversal<Order, LineItem> bulkItems =
allItems.andThen(Traversals.filtered(item -> item.quantity() > 10));
// Apply 10% discount to bulk items
Order order = new Order("ORD-001", List.of(
new LineItem("PROD-1", 5, new Money(100)),
new LineItem("PROD-2", 15, new Money(200)),
new LineItem("PROD-3", 20, new Money(150))
));
Order discounted = Traversals.modify(
bulkItems,
item -> new LineItem(
item.productId(),
item.quantity(),
item.price().multiply(0.9)
),
order
);
// Only items with quantity > 10 get the discount
Recipe 4: Extracting Values from Polymorphic Structures
Problem
You have a list of mixed types and need to extract values from specific types only.
Solution
sealed interface Event permits UserEvent, SystemEvent {}
record UserEvent(String userId, String action) implements Event {}
record SystemEvent(String level, String message) implements Event {}
// Prism to focus on UserEvents
Prism<Event, UserEvent> userEventPrism = Prism.of(
e -> e instanceof UserEvent u ? Optional.of(u) : Optional.empty(),
u -> u
);
// Traversal from list of events to user actions
Traversal<List<Event>, String> userActions =
Traversals.<Event>forList()
.andThen(userEventPrism.asTraversal())
.andThen(UserEventLenses.action().asTraversal());
// Usage
List<Event> events = List.of(
new UserEvent("user-1", "LOGIN"),
new SystemEvent("INFO", "Server started"),
new UserEvent("user-2", "LOGOUT"),
new SystemEvent("WARN", "High memory usage")
);
List<String> actions = Traversals.getAll(userActions, events);
// Result: ["LOGIN", "LOGOUT"]
Recipe 5: Safe Map Access with Fallback
Problem
You need to access a value in a Map that may not exist, with a sensible default.
Solution
record Config(Map<String, String> settings) {}
// Traversal to a specific key
Traversal<Config, String> databaseUrl =
ConfigLenses.settings().asTraversal()
.andThen(Traversals.forMap("database.url"));
// Get with default
Config config = new Config(Map.of("app.name", "MyApp"));
List<String> urls = Traversals.getAll(databaseUrl, config);
String url = urls.isEmpty() ? "jdbc:postgresql://localhost/default" : urls.get(0);
// Or use Optional pattern
Optional<String> maybeUrl = urls.stream().findFirst();
Recipe 6: Composing Multiple Validations
Problem
You need to validate multiple fields and accumulate all errors.
Solution
import static org.higherkindedj.optics.fluent.OpticOps.modifyAllValidated;
record Registration(String email, String password, int age) {}
// Traversals for each field
Traversal<Registration, String> emailTraversal =
RegistrationLenses.email().asTraversal();
Traversal<Registration, String> passwordTraversal =
RegistrationLenses.password().asTraversal();
Traversal<Registration, Integer> ageTraversal =
RegistrationLenses.age().asTraversal();
// Validation functions
Function<String, Validated<List<String>, String>> validateEmail = email ->
email.contains("@")
? Validated.valid(email)
: Validated.invalid(List.of("Invalid email format"));
Function<String, Validated<List<String>, String>> validatePassword = password ->
password.length() >= 8
? Validated.valid(password)
: Validated.invalid(List.of("Password must be at least 8 characters"));
Function<Integer, Validated<List<String>, Integer>> validateAge = age ->
age >= 18
? Validated.valid(age)
: Validated.invalid(List.of("Must be 18 or older"));
// Combine validations
public Validated<List<String>, Registration> validateRegistration(Registration reg) {
Validated<List<String>, Registration> emailResult =
modifyAllValidated(emailTraversal, validateEmail, reg);
Validated<List<String>, Registration> passwordResult =
modifyAllValidated(passwordTraversal, validatePassword, reg);
Validated<List<String>, Registration> ageResult =
modifyAllValidated(ageTraversal, validateAge, reg);
// Combine all validations
return emailResult.flatMap(r1 ->
passwordResult.flatMap(r2 ->
ageResult
)
);
}
Recipe 7: Transforming Nested Collections
Problem
You have nested collections and need to transform items at the innermost level.
Solution
record Company(List<Department> departments) {}
record Department(String name, List<Employee> employees) {}
record Employee(String name, int salary) {}
// Traversal to all employee salaries across all departments
Traversal<Company, Integer> allSalaries =
CompanyLenses.departments().asTraversal()
.andThen(Traversals.forList())
.andThen(DepartmentLenses.employees().asTraversal())
.andThen(Traversals.forList())
.andThen(EmployeeLenses.salary().asTraversal());
// Give everyone a 5% raise
Company company = /* ... */;
Company afterRaise = Traversals.modify(
allSalaries,
salary -> (int) (salary * 1.05),
company
);
// Get total payroll
List<Integer> salaries = Traversals.getAll(allSalaries, company);
int totalPayroll = salaries.stream().mapToInt(Integer::intValue).sum();
Recipe 8: Conditional Updates Based on Related Data
Problem
You need to update a field based on the value of another field in the same structure.
Solution
record Product(String name, Money price, boolean onSale) {}
// Create a lens for the price
Lens<Product, Money> priceLens = ProductLenses.price();
// Conditional discount based on onSale flag
public Product applyDiscount(Product product, double discountRate) {
if (product.onSale()) {
return priceLens.modify(
price -> price.multiply(1 - discountRate),
product
);
}
return product;
}
// Or using Traversal with filter
Traversal<List<Product>, Money> salePrices =
Traversals.<Product>forList()
.andThen(Traversals.filtered(Product::onSale))
.andThen(priceLens.asTraversal());
List<Product> products = /* ... */;
List<Product> discounted = Traversals.modify(
salePrices,
price -> price.multiply(0.8),
products
);
Recipe 9: Working with Either for Error Handling
Problem
You have an Either<Error, Success> and need to transform the success case whilst preserving errors.
Solution
record ValidationError(String field, String message) {}
record UserData(String name, String email) {}
// Prism to focus on the Right (success) case
Prism<Either<ValidationError, UserData>, UserData> rightPrism = Prisms.right();
// Transform user data only on success
Either<ValidationError, UserData> result =
Either.right(new UserData("alice", "alice@example.com"));
Either<ValidationError, UserData> transformed = rightPrism.modify(
user -> new UserData(user.name().toUpperCase(), user.email()),
result
);
// Result: Right(UserData[name=ALICE, email=alice@example.com])
// Errors pass through unchanged
Either<ValidationError, UserData> errorResult =
Either.left(new ValidationError("email", "Invalid format"));
Either<ValidationError, UserData> stillError = rightPrism.modify(
user -> new UserData(user.name().toUpperCase(), user.email()),
errorResult
);
// Result: Left(ValidationError[field=email, message=Invalid format])
Recipe 10: Sorting or Reversing Traversed Elements
Problem
You need to sort or reorder the elements focused by a Traversal.
Solution
record Scoreboard(List<Player> players) {}
record Player(String name, int score) {}
// Traversal to all scores
Traversal<Scoreboard, Integer> scores =
ScoreboardLenses.players().asTraversal()
.andThen(Traversals.forList())
.andThen(PlayerLenses.score().asTraversal());
// Sort scores (highest first)
Scoreboard board = new Scoreboard(List.of(
new Player("Alice", 100),
new Player("Bob", 150),
new Player("Charlie", 75)
));
Scoreboard sorted = Traversals.sorted(
scores,
Comparator.reverseOrder(),
board
);
// Result: Players now have scores [150, 100, 75] respectively
// Reverse the order
Scoreboard reversed = Traversals.reversed(scores, board);
// Result: Players now have scores [75, 150, 100]
Best Practices
1. Create Reusable Optic Constants
public final class OrderOptics {
public static final Traversal<Order, Money> ALL_PRICES =
OrderLenses.items().asTraversal()
.andThen(Traversals.forList())
.andThen(LineItemLenses.price().asTraversal());
public static final Traversal<Order, String> CUSTOMER_EMAIL =
OrderLenses.customer()
.andThen(CustomerPrisms.verified())
.andThen(CustomerLenses.email().asTraversal());
}
2. Use Direct Composition Methods
// Preferred: type-safe, clearer intent
Traversal<Config, Settings> direct = configLens.andThen(settingsPrism);
// Fallback: when you need maximum flexibility
Traversal<Config, Settings> manual =
configLens.asTraversal().andThen(settingsPrism.asTraversal());
3. Document Complex Compositions
/**
* Traverses from an Order to all active promotion codes.
*
* Path: Order -> Customer -> Loyalty (if exists) -> Promotions list -> Active only
*/
public static final Traversal<Order, String> ACTIVE_PROMO_CODES =
OrderLenses.customer()
.andThen(CustomerPrisms.loyaltyMember())
.andThen(LoyaltyLenses.promotions().asTraversal())
.andThen(Traversals.forList())
.andThen(Traversals.filtered(Promotion::isActive))
.andThen(PromotionLenses.code().asTraversal());
4. Prefer Specific Types When Available
// If you know it's always present, use Lens directly
Lens<User, String> name = UserLenses.name();
String userName = name.get(user);
// Only use Traversal when you need the flexibility
Traversal<User, String> optionalNickname = /* ... */;
List<String> nicknames = Traversals.getAll(optionalNickname, user);
Recipe 10b: Extracting Values from Multiple Paths
Problem
You have values scattered across different fields or branches of a data structure and need to extract them all into a single collection.
Solution
record Team(String name, Employee lead, List<Employee> members) {}
record Employee(String name, String email) {}
// Folds for different paths
Fold<Team, String> leadEmail = teamLeadLens.asFold()
.andThen(employeeEmailLens.asFold());
Fold<Team, String> memberEmails = Fold.<Team, Employee>of(Team::members)
.andThen(employeeEmailLens.asFold());
// Combine with plus
Fold<Team, String> allEmails = leadEmail.plus(memberEmails);
// Or use sum for three or more paths
Fold<Team, String> allNames = Fold.sum(
Fold.of(t -> List.of(t.name())),
teamLeadLens.asFold().andThen(employeeNameLens.asFold()),
Fold.<Team, Employee>of(Team::members).andThen(employeeNameLens.asFold())
);
// Usage
Team team = new Team("Backend",
new Employee("Alice", "alice@co"),
List.of(new Employee("Bob", "bob@co")));
List<String> emails = allEmails.getAll(team);
// Result: ["alice@co", "bob@co"]
List<String> names = allNames.getAll(team);
// Result: ["Backend", "Alice", "Bob"]
Why It Works
Fold.plus() delegates foldMap to both constituent folds and combines results via the monoid. This means all fold operations (getAll, exists, foldMap, length, etc.) work correctly across the combined paths. The ordering is deterministic: results from the first fold appear before results from the second.
Recipe 10c: Traversal-Derived Folds for Read-Only Queries
Problem
You have a Traversal built for modifications, but now need to perform read-only aggregation, counting, or existence checks on the same path.
Solution
record Order(String id, List<LineItem> items) {}
record LineItem(String product, int quantity, double price) {}
// Existing traversal for modifications
Traversal<Order, Double> allPrices =
OrderLenses.items().asTraversal()
.andThen(Traversals.forList())
.andThen(LineItemLenses.price().asTraversal());
// Convert to fold for read-only queries
Fold<Order, Double> pricesFold = allPrices.asFold();
Order order = new Order("ORD-1", List.of(
new LineItem("Widget", 2, 29.99),
new LineItem("Gadget", 1, 149.99),
new LineItem("Gizmo", 3, 9.99)
));
// Aggregation with foldMap
double total = pricesFold.foldMap(Monoids.doubleAddition(), p -> p, order);
// Result: 189.97
// Query operations
boolean hasExpensive = pricesFold.exists(p -> p > 100.0, order);
// Result: true
boolean allAffordable = pricesFold.all(p -> p < 200.0, order);
// Result: true
int itemCount = pricesFold.length(order);
// Result: 3
Why It Works
Traversal.asFold() reuses the traversal's modifyF with a Const applicative that accumulates monoidal values instead of modifying the structure. This gives you the full Fold API (foldMap, exists, all, length, preview, plus) whilst traversing the structure only once. Use this pattern when you need richer query operations than Traversals.getAll() provides, or when combining traversal paths with Fold.plus() for multi-path extraction.
Focus DSL Recipes
The following recipes demonstrate the Focus DSL for more ergonomic optic usage.
Recipe 11: Nested Record Updates with Focus DSL
Problem
You have deeply nested records and want to update values without verbose composition.
Solution
record Company(String name, List<Department> departments) {}
record Department(String name, List<Employee> employees) {}
record Employee(String name, int salary) {}
// Using Focus DSL instead of manual lens composition
FocusPath<Company, List<Department>> deptPath = FocusPath.of(companyDeptsLens);
TraversalPath<Company, Employee> allEmployees = deptPath.each().via(deptEmployeesLens).each();
TraversalPath<Company, Integer> allSalaries = allEmployees.via(employeeSalaryLens);
// Give everyone a 5% raise
Company updated = allSalaries.modifyAll(s -> (int) (s * 1.05), company);
// Or use generated Focus classes (with @GenerateFocus)
Company updated = CompanyFocus.departments().employees().salary()
.modifyAll(s -> (int) (s * 1.05), company);
Why It Works
The Focus DSL tracks path type transitions automatically, from FocusPath through collections to TraversalPath, whilst maintaining full type safety.
Recipe 12: Sum Type Handling with instanceOf()
Problem
You have a sealed interface and want to work with specific variants using the Focus DSL.
Solution
sealed interface Notification permits Email, SMS, Push {}
record Email(String address, String subject, String body) implements Notification {}
record SMS(String phone, String message) implements Notification {}
record Push(String token, String title) implements Notification {}
record User(String name, List<Notification> notifications) {}
// Focus on Email notifications only
TraversalPath<User, Notification> allNotifications =
FocusPath.of(userNotificationsLens).each();
TraversalPath<User, Email> emailsOnly =
allNotifications.via(AffinePath.instanceOf(Email.class));
// Get all email addresses
List<String> emailAddresses = emailsOnly
.via(emailAddressLens)
.getAll(user);
// Update all email subjects
User updated = emailsOnly
.via(emailSubjectLens)
.modifyAll(subject -> "[URGENT] " + subject, user);
Recipe 13: Generic Collection Traversal with traverseOver()
Problem
Your data contains Kind-wrapped collections (e.g., Kind<ListKind.Witness, T>) rather than raw List<T>.
Solution
record Team(String name, Kind<ListKind.Witness, Member> members) {}
record Member(String name, Kind<ListKind.Witness, Role> roles) {}
record Role(String name, int level) {}
// Path to all roles across all members
FocusPath<Team, Kind<ListKind.Witness, Member>> membersPath =
FocusPath.of(teamMembersLens);
TraversalPath<Team, Member> allMembers =
membersPath.<ListKind.Witness, Member>traverseOver(ListTraverse.INSTANCE);
TraversalPath<Team, Kind<ListKind.Witness, Role>> memberRoles =
allMembers.via(memberRolesLens);
TraversalPath<Team, Role> allRoles =
memberRoles.<ListKind.Witness, Role>traverseOver(ListTraverse.INSTANCE);
// Promote all high-level roles
Team updated = allRoles.modifyWhen(
r -> r.level() >= 5,
r -> new Role(r.name(), r.level() + 1),
team
);
Recipe 14: Validation Pipelines with modifyF()
Problem
You need to validate and transform data, accumulating errors or short-circuiting on failure.
Solution
record Config(String apiKey, String dbUrl, int timeout) {}
FocusPath<Config, String> apiKeyPath = FocusPath.of(configApiKeyLens);
FocusPath<Config, String> dbUrlPath = FocusPath.of(configDbUrlLens);
// Validation function returning Maybe (short-circuits on Nothing)
Function<String, Kind<MaybeKind.Witness, String>> validateApiKey = key -> {
if (key != null && key.length() >= 10) {
return MaybeKindHelper.MAYBE.widen(Maybe.just(key.toUpperCase()));
}
return MaybeKindHelper.MAYBE.widen(Maybe.nothing());
};
// Apply validation (MaybeMonad extends Applicative)
Kind<MaybeKind.Witness, Config> result =
apiKeyPath.modifyF(validateApiKey, config, MaybeMonad.INSTANCE);
Maybe<Config> validated = MaybeKindHelper.MAYBE.narrow(result);
if (validated.isJust()) {
Config validConfig = validated.get();
// Proceed with valid config
} else {
// Handle validation failure
}
Recipe 15: Aggregation with foldMap()
Problem
You need to aggregate values across a traversal (sum, max, concatenate, etc.).
Solution
record Order(List<LineItem> items) {}
record LineItem(String name, int quantity, BigDecimal price) {}
TraversalPath<Order, LineItem> allItems = FocusPath.of(orderItemsLens).each();
// Sum quantities using integer addition monoid
Monoid<Integer> intSum = new Monoid<>() {
@Override public Integer empty() { return 0; }
@Override public Integer combine(Integer a, Integer b) { return a + b; }
};
int totalQuantity = allItems
.via(lineItemQuantityLens)
.foldMap(intSum, q -> q, order);
// Sum prices using BigDecimal monoid
Monoid<BigDecimal> decimalSum = new Monoid<>() {
@Override public BigDecimal empty() { return BigDecimal.ZERO; }
@Override public BigDecimal combine(BigDecimal a, BigDecimal b) { return a.add(b); }
};
BigDecimal totalPrice = allItems
.via(lineItemPriceLens)
.foldMap(decimalSum, p -> p, order);
// Collect all item names
Monoid<List<String>> listConcat = new Monoid<>() {
@Override public List<String> empty() { return List.of(); }
@Override public List<String> combine(List<String> a, List<String> b) {
var result = new ArrayList<>(a);
result.addAll(b);
return result;
}
};
List<String> allNames = allItems.foldMap(
listConcat,
item -> List.of(item.name()),
order
);
Recipe 16: Debugging Complex Paths with traced()
Problem
You have a complex path composition and need to understand what values are being accessed.
Solution
record System(List<Server> servers) {}
record Server(String hostname, List<Service> services) {}
record Service(String name, Status status) {}
TraversalPath<System, Service> allServices =
FocusPath.of(systemServersLens).each().via(serverServicesLens).each();
// Add tracing to observe navigation
TraversalPath<System, Service> tracedServices = allServices.traced(
(system, services) -> {
System.out.println("Accessing " + services.size() + " services");
for (Service s : services) {
System.out.println(" - " + s.name() + ": " + s.status());
}
}
);
// Every getAll() now logs
List<Service> services = tracedServices.getAll(system);
// Output:
// Accessing 5 services
// - api: RUNNING
// - db: RUNNING
// - cache: STOPPED
// - ...
// Note: traced() only observes getAll(), not modifyAll()
Recipe 17: Conditional Updates with modifyWhen()
Problem
You need to update only elements that match a predicate, leaving others unchanged.
Solution
record Inventory(List<Product> products) {}
record Product(String name, int stock, BigDecimal price, Category category) {}
enum Category { ELECTRONICS, CLOTHING, FOOD }
TraversalPath<Inventory, Product> allProducts =
FocusPath.of(inventoryProductsLens).each();
// Apply 20% discount to electronics with low stock
Inventory updated = allProducts.modifyWhen(
p -> p.category() == Category.ELECTRONICS && p.stock() < 10,
p -> new Product(p.name(), p.stock(), p.price().multiply(new BigDecimal("0.80")), p.category()),
inventory
);
// Clear stock only for food items
Inventory cleared = allProducts.modifyWhen(
p -> p.category() == Category.FOOD,
p -> new Product(p.name(), 0, p.price(), p.category()),
inventory
);
Practice real-world optics patterns in Tutorial 08: Real World Optics (6 exercises, ~10 minutes).
Previous: Optics Extensions Next: Auditing Complex Data