Optics Cookbook

Practical Recipes for Common Problems

What You'll Learn

  • 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();

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);

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
);

Hands-On Learning

Practice real-world optics patterns in Tutorial 08: Real World Optics (6 exercises, ~10 minutes).


Previous: Composition Rules Next: Auditing Complex Data