Setters: A Practical Guide

Composable Write-Only Modifications

What You'll Learn

  • How to modify data structures using composable, write-only optics
  • Using @GenerateSetters to create type-safe modifiers automatically
  • Understanding the relationship between Setter and Traversal
  • Creating modification pipelines without read access
  • Effectful modifications using Applicative contexts
  • Factory methods: of, fromGetSet, forList, forMapValues, identity
  • When to use Setter vs Lens vs Traversal
  • Building batch update and normalisation pipelines

Example Code

In the previous guide, we explored Getter for composable read-only access. Now we turn to its dual: Setter, a write-only optic that modifies data without necessarily reading it first.

A Setter is an optic that focuses on transforming elements within a structure. Unlike a Lens, which provides both getting and setting, a Setter concentrates solely on modification—making it ideal for batch updates, data normalisation, and transformation pipelines where read access isn't required.


The Scenario: User Management System

Consider a user management system where you need to perform various modifications:

The Data Model:

@GenerateSetters
public record User(String username, String email, int loginCount, UserSettings settings) {}

@GenerateSetters
public record UserSettings(
    String theme, boolean notifications, int fontSize, Map<String, String> preferences) {}

@GenerateSetters
public record Product(String name, double price, int stock, List<String> tags) {}

@GenerateSetters
public record Inventory(List<Product> products, String warehouseId) {}

Common Modification Needs:

  • "Normalise all usernames to lowercase"
  • "Increment login count after authentication"
  • "Apply 10% discount to all products"
  • "Restock all items by 10 units"
  • "Convert all product names to title case"
  • "Set all user themes to dark mode"

A Setter makes these modifications type-safe, composable, and expressive.


Think of Setters Like...

  • A functional modifier ✏️: Transforming values without reading
  • A write-only lens 🎯: Focusing on modification only
  • A batch transformer 🔄: Applying changes to multiple elements
  • A data normalisation tool 📐: Standardising formats across structures
  • A pipeline stage ⚙️: Composable modification steps

Setter vs Lens vs Traversal: Understanding the Differences

AspectSetterLensTraversal
FocusOne or more elementsExactly one elementZero or more elements
Can read?❌ No (typically)✅ Yes✅ Yes
Can modify?✅ Yes✅ Yes✅ Yes
Core operationsmodify, setget, set, modifymodifyF, getAll
Use caseWrite-only pipelinesRead-write field accessCollection traversals
Intent"Transform these values""Get or set this field""Update all these elements"

Key Insight: A Setter can be viewed as the write-only half of a Lens. It extends Optic, enabling composition with other optics and supporting effectful modifications via modifyF. Choose Setter when you want to emphasise write-only intent or when read access isn't needed.


A Step-by-Step Walkthrough

Step 1: Creating Setters

Using @GenerateSetters Annotation

Annotating a record with @GenerateSetters creates a companion class (e.g., UserSetters) containing a Setter for each field:

import org.higherkindedj.optics.annotations.GenerateSetters;

@GenerateSetters
public record User(String username, String email, int loginCount, UserSettings settings) {}

This generates:

  • UserSetters.username()Setter<User, String>
  • UserSetters.email()Setter<User, String>
  • UserSetters.loginCount()Setter<User, Integer>
  • UserSetters.settings()Setter<User, UserSettings>

Plus convenience methods:

  • UserSetters.withUsername(user, newUsername)User
  • UserSetters.withEmail(user, newEmail)User
  • etc.

Using Factory Methods

Create Setters programmatically:

// Using fromGetSet for single-element focus
Setter<User, String> usernameSetter = Setter.fromGetSet(
    User::username,
    (user, newUsername) -> new User(newUsername, user.email(), user.loginCount(), user.settings()));

// Using of for transformation-based definition
Setter<Person, String> nameSetter = Setter.of(
    f -> person -> new Person(f.apply(person.name()), person.age()));

// Built-in collection setters
Setter<List<Integer>, Integer> listSetter = Setter.forList();
Setter<Map<String, Double>, Double> mapValuesSetter = Setter.forMapValues();

Step 2: Core Setter Operations

modify(function, source): Transform the Focused Value

Applies a function to modify the focused element:

Setter<User, String> usernameSetter = Setter.fromGetSet(
    User::username,
    (u, name) -> new User(name, u.email(), u.loginCount(), u.settings()));

User user = new User("JOHN_DOE", "john@example.com", 10, settings);

// Transform username to lowercase
User normalised = usernameSetter.modify(String::toLowerCase, user);
// Result: User("john_doe", "john@example.com", 10, settings)

// Append suffix
User suffixed = usernameSetter.modify(name -> name + "_admin", user);
// Result: User("JOHN_DOE_admin", "john@example.com", 10, settings)

set(value, source): Replace the Focused Value

Sets all focused elements to a specific value:

Setter<User, Integer> loginCountSetter = Setter.fromGetSet(
    User::loginCount,
    (u, count) -> new User(u.username(), u.email(), count, u.settings()));

User user = new User("john", "john@example.com", 10, settings);
User reset = loginCountSetter.set(0, user);
// Result: User("john", "john@example.com", 0, settings)

Step 3: Composing Setters

Chain Setters together for deep modifications:

Setter<User, UserSettings> settingsSetter = Setter.fromGetSet(
    User::settings,
    (u, s) -> new User(u.username(), u.email(), u.loginCount(), s));

Setter<UserSettings, String> themeSetter = Setter.fromGetSet(
    UserSettings::theme,
    (s, theme) -> new UserSettings(theme, s.notifications(), s.fontSize(), s.preferences()));

// Compose: User → UserSettings → String
Setter<User, String> userThemeSetter = settingsSetter.andThen(themeSetter);

User user = new User("john", "john@example.com", 10,
    new UserSettings("light", true, 14, Map.of()));

User darkModeUser = userThemeSetter.set("dark", user);
// Result: User with settings.theme = "dark"

Deep Composition Chain

Setter<User, UserSettings> settingsSetter = /* ... */;
Setter<UserSettings, Integer> fontSizeSetter = /* ... */;

Setter<User, Integer> userFontSizeSetter = settingsSetter.andThen(fontSizeSetter);

User largerFont = userFontSizeSetter.modify(size -> size + 2, user);
// Result: User with settings.fontSize increased by 2

Step 4: Collection Setters

Higher-Kinded-J provides built-in Setters for collections:

forList(): Modify All List Elements

Setter<List<Integer>, Integer> listSetter = Setter.forList();

List<Integer> numbers = List.of(1, 2, 3, 4, 5);

// Double all values
List<Integer> doubled = listSetter.modify(x -> x * 2, numbers);
// Result: [2, 4, 6, 8, 10]

// Set all to same value
List<Integer> allZeros = listSetter.set(0, numbers);
// Result: [0, 0, 0, 0, 0]

forMapValues(): Modify All Map Values

Setter<Map<String, Integer>, Integer> mapSetter = Setter.forMapValues();

Map<String, Integer> scores = Map.of("Alice", 85, "Bob", 90, "Charlie", 78);

// Add 5 points to all scores
Map<String, Integer> curved = mapSetter.modify(score -> Math.min(100, score + 5), scores);
// Result: {Alice=90, Bob=95, Charlie=83}

// Reset all scores
Map<String, Integer> reset = mapSetter.set(0, scores);
// Result: {Alice=0, Bob=0, Charlie=0}

Step 5: Nested Collection Setters

Compose Setters for complex nested modifications:

Setter<Inventory, List<Product>> productsSetter = Setter.fromGetSet(
    Inventory::products,
    (inv, prods) -> new Inventory(prods, inv.warehouseId()));

Setter<List<Product>, Product> productListSetter = Setter.forList();

Setter<Product, Double> priceSetter = Setter.fromGetSet(
    Product::price,
    (p, price) -> new Product(p.name(), price, p.stock(), p.tags()));

// Compose: Inventory → List<Product> → Product
Setter<Inventory, Product> allProductsSetter = productsSetter.andThen(productListSetter);

Inventory inventory = new Inventory(
    List.of(
        new Product("Laptop", 999.99, 50, List.of("electronics")),
        new Product("Keyboard", 79.99, 100, List.of("accessories")),
        new Product("Monitor", 299.99, 30, List.of("displays"))),
    "WH-001");

// Apply 10% discount to all products
Inventory discounted = allProductsSetter.modify(
    product -> priceSetter.modify(price -> price * 0.9, product),
    inventory);
// Result: All product prices reduced by 10%

// Restock all products
Setter<Product, Integer> stockSetter = Setter.fromGetSet(
    Product::stock,
    (p, stock) -> new Product(p.name(), p.price(), stock, p.tags()));

Inventory restocked = allProductsSetter.modify(
    product -> stockSetter.modify(stock -> stock + 10, product),
    inventory);
// Result: All product stock increased by 10

Step 6: Effectful Modifications

Setters support effectful modifications via modifyF, allowing you to compose modifications that might fail or have side effects:

Setter<User, String> usernameSetter = Setter.fromGetSet(
    User::username,
    (u, name) -> new User(name, u.email(), u.loginCount(), u.settings()));

// Validation: username must be at least 3 characters and lowercase
Function<String, Kind<OptionalKind.Witness, String>> validateUsername = username -> {
    if (username.length() >= 3 && username.matches("[a-z_]+")) {
        return OptionalKindHelper.OPTIONAL.widen(Optional.of(username));
    } else {
        return OptionalKindHelper.OPTIONAL.widen(Optional.empty());
    }
};

User validUser = new User("john_doe", "john@example.com", 10, settings);
Kind<OptionalKind.Witness, User> result =
    usernameSetter.modifyF(validateUsername, validUser, OptionalMonad.INSTANCE);

Optional<User> validated = OptionalKindHelper.OPTIONAL.narrow(result);
// Result: Optional[User with validated username]

User invalidUser = new User("ab", "a@test.com", 0, settings); // Too short
Kind<OptionalKind.Witness, User> invalidResult =
    usernameSetter.modifyF(validateUsername, invalidUser, OptionalMonad.INSTANCE);

Optional<User> invalidValidated = OptionalKindHelper.OPTIONAL.narrow(invalidResult);
// Result: Optional.empty (validation failed)

Sequencing Effects in Collections

Setter<List<Integer>, Integer> listSetter = Setter.forList();

List<Integer> numbers = List.of(1, 2, 3);

Function<Integer, Kind<OptionalKind.Witness, Integer>> doubleIfPositive = n -> {
    if (n > 0) {
        return OptionalKindHelper.OPTIONAL.widen(Optional.of(n * 2));
    } else {
        return OptionalKindHelper.OPTIONAL.widen(Optional.empty());
    }
};

Kind<OptionalKind.Witness, List<Integer>> result =
    listSetter.modifyF(doubleIfPositive, numbers, OptionalMonad.INSTANCE);

Optional<List<Integer>> doubled = OptionalKindHelper.OPTIONAL.narrow(result);
// Result: Optional[[2, 4, 6]]

// With negative number (will fail)
List<Integer> withNegative = List.of(1, -2, 3);
Kind<OptionalKind.Witness, List<Integer>> failedResult =
    listSetter.modifyF(doubleIfPositive, withNegative, OptionalMonad.INSTANCE);

Optional<List<Integer>> failed = OptionalKindHelper.OPTIONAL.narrow(failedResult);
// Result: Optional.empty (validation failed on -2)

Step 7: Converting to Traversal

Setters can be viewed as Traversals, enabling integration with other optics:

Setter<User, String> nameSetter = Setter.fromGetSet(
    User::username,
    (u, name) -> new User(name, u.email(), u.loginCount(), u.settings()));

Traversal<User, String> nameTraversal = nameSetter.asTraversal();

// Now you can use Traversal operations
Function<String, Kind<OptionalKind.Witness, String>> toUpper =
    s -> OptionalKindHelper.OPTIONAL.widen(Optional.of(s.toUpperCase()));

Kind<OptionalKind.Witness, User> result =
    nameTraversal.modifyF(toUpper, user, OptionalMonad.INSTANCE);

Built-in Helper Setters

identity(): Modifies the Source Itself

Setter<String, String> identitySetter = Setter.identity();

String result = identitySetter.modify(String::toUpperCase, "hello");
// Result: "HELLO"

String replaced = identitySetter.set("world", "hello");
// Result: "world"

Useful as a base case in composition or for direct value transformation.


When to Use Setter vs Other Approaches

Use Setter When:

  • You need write-only access without reading
  • You're building batch transformation pipelines
  • You want clear modification intent in your code
  • You need effectful modifications with validation
  • You're performing data normalisation across structures
// Good: Batch normalisation
Setter<List<String>, String> listSetter = Setter.forList();
List<String> normalised = listSetter.modify(String::trim, rawStrings);

// Good: Composable deep modification
Setter<Company, String> employeeNamesSetter = companySetter
    .andThen(employeesSetter)
    .andThen(personNameSetter);

Use Lens When:

  • You need both reading and writing
  • You want to get and set the same field
// Use Lens when you need to read
Lens<User, String> usernameLens = Lens.of(
    User::username,
    (u, name) -> new User(name, u.email(), u.loginCount(), u.settings()));

String current = usernameLens.get(user); // Read
User updated = usernameLens.set("new_name", user); // Write

Use Traversal When:

  • You need read operations (getAll) on collections
  • You're working with optional or multiple focuses
// Use Traversal when you need to extract values too
Traversal<Order, Product> productTraversal = /* ... */;
List<Product> all = Traversals.getAll(productTraversal, order); // Read

Use Direct Mutation When:

  • You're working with mutable objects (not recommended in FP)
  • Performance is absolutely critical
// Direct mutation (only for mutable objects)
user.setUsername("new_name"); // Avoid in functional programming

Real-World Use Cases

Data Normalisation Pipeline

Setter<List<Product>, Product> productSetter = Setter.forList();
Setter<Product, String> nameSetter = Setter.fromGetSet(
    Product::name,
    (p, name) -> new Product(name, p.price(), p.stock(), p.tags()));

Function<String, String> normalise = name -> {
    String trimmed = name.trim();
    return trimmed.substring(0, 1).toUpperCase() +
           trimmed.substring(1).toLowerCase();
};

List<Product> rawProducts = List.of(
    new Product("  LAPTOP  ", 999.99, 50, List.of()),
    new Product("keyboard", 79.99, 100, List.of()),
    new Product("MONITOR", 299.99, 30, List.of()));

List<Product> normalised = productSetter.modify(
    product -> nameSetter.modify(normalise, product),
    rawProducts);
// Result: [Product("Laptop", ...), Product("Keyboard", ...), Product("Monitor", ...)]

Currency Conversion

Setter<Product, Double> priceSetter = /* ... */;
double exchangeRate = 0.92; // USD to EUR

List<Product> euroProducts = productSetter.modify(
    product -> priceSetter.modify(price -> price * exchangeRate, product),
    usdProducts);

Batch User Updates

Setter<List<User>, User> usersSetter = Setter.forList();
Setter<User, Integer> loginCountSetter = /* ... */;

// Reset all login counts
List<User> resetUsers = usersSetter.modify(
    user -> loginCountSetter.set(0, user),
    users);

// Increment all login counts
List<User> incremented = usersSetter.modify(
    user -> loginCountSetter.modify(count -> count + 1, user),
    users);

Theme Migration

Setter<User, String> userThemeSetter = settingsSetter.andThen(themeSetter);

// Migrate all users to dark mode
List<User> darkModeUsers = usersSetter.modify(
    user -> userThemeSetter.set("dark", user),
    users);

Common Pitfalls

❌ Don't Use Setter.of() for Effectful Operations

// Warning: Setter.of() doesn't support modifyF properly
Setter<Person, String> nameSetter = Setter.of(
    f -> person -> new Person(f.apply(person.name()), person.age()));

// This will throw UnsupportedOperationException!
nameSetter.modifyF(validateFn, person, applicative);

✅ Use fromGetSet() for Effectful Support

// Correct: fromGetSet supports modifyF
Setter<Person, String> nameSetter = Setter.fromGetSet(
    Person::name,
    (p, name) -> new Person(name, p.age()));

// Works correctly
nameSetter.modifyF(validateFn, person, applicative);

❌ Don't Forget Immutability

// Wrong: Modifying in place (if mutable)
setter.modify(obj -> { obj.setValue(newValue); return obj; }, source);

✅ Always Return New Instances

// Correct: Return new immutable instance
Setter<Product, Double> priceSetter = Setter.fromGetSet(
    Product::price,
    (p, price) -> new Product(p.name(), price, p.stock(), p.tags()));

Performance Considerations

Setters are lightweight and efficient:

  • Minimal overhead: Just function composition
  • No reflection: Direct method calls
  • Lazy application: Modifications only applied when executed
  • JIT-friendly: Can be inlined by the JVM
  • O(n) collection operations: forList() and forMapValues() are optimised to avoid quadratic time complexity

Optimised Collection Operations

The modifyF implementations in forList() and forMapValues() use efficient algorithms:

  • Right-to-left folding: Uses LinkedList with O(1) prepending instead of repeated array copying
  • Single pass construction: Collects effects first, sequences them, then builds the final collection once
  • Linear time complexity: O(n) for lists and maps with n elements

This means you can safely use effectful modifications on large collections without performance concerns:

// Efficient even for large lists
Setter<List<Integer>, Integer> listSetter = Setter.forList();
List<Integer> largeList = /* thousands of elements */;

// O(n) time complexity, not O(n²)
Kind<OptionalKind.Witness, List<Integer>> result =
    listSetter.modifyF(validateAndTransform, largeList, OptionalMonad.INSTANCE);

Best Practice: Compose Setters at initialisation time, then reuse:

// Define once
private static final Setter<Company, Double> ALL_PRODUCT_PRICES =
    companySetter.andThen(productsSetter).andThen(priceSetter);

// Reuse many times
Company discounted = ALL_PRODUCT_PRICES.modify(p -> p * 0.9, company);
Company inflated = ALL_PRODUCT_PRICES.modify(p -> p * 1.05, company);

Complete, Runnable Example

import org.higherkindedj.optics.Setter;
import org.higherkindedj.hkt.Kind;
import org.higherkindedj.hkt.optional.OptionalKind;
import org.higherkindedj.hkt.optional.OptionalKindHelper;
import org.higherkindedj.hkt.optional.OptionalMonad;
import java.util.*;
import java.util.function.Function;

public class SetterExample {

    public record User(String username, String email, int loginCount, UserSettings settings) {}
    public record UserSettings(String theme, boolean notifications, int fontSize) {}
    public record Product(String name, double price, int stock) {}

    public static void main(String[] args) {
        // === Basic Setters ===
        Setter<User, String> usernameSetter = Setter.fromGetSet(
            User::username,
            (u, name) -> new User(name, u.email(), u.loginCount(), u.settings()));

        Setter<User, Integer> loginCountSetter = Setter.fromGetSet(
            User::loginCount,
            (u, count) -> new User(u.username(), u.email(), count, u.settings()));

        UserSettings settings = new UserSettings("light", true, 14);
        User user = new User("JOHN_DOE", "john@example.com", 10, settings);

        // Normalise username
        User normalised = usernameSetter.modify(String::toLowerCase, user);
        System.out.println("Normalised: " + normalised.username());

        // Increment login count
        User incremented = loginCountSetter.modify(count -> count + 1, user);
        System.out.println("Login count: " + incremented.loginCount());

        // === Composition ===
        Setter<User, UserSettings> settingsSetter = Setter.fromGetSet(
            User::settings,
            (u, s) -> new User(u.username(), u.email(), u.loginCount(), s));

        Setter<UserSettings, String> themeSetter = Setter.fromGetSet(
            UserSettings::theme,
            (s, theme) -> new UserSettings(theme, s.notifications(), s.fontSize()));

        Setter<User, String> userThemeSetter = settingsSetter.andThen(themeSetter);

        User darkMode = userThemeSetter.set("dark", user);
        System.out.println("Theme: " + darkMode.settings().theme());

        // === Collection Setters ===
        Setter<List<Integer>, Integer> listSetter = Setter.forList();

        List<Integer> numbers = List.of(1, 2, 3, 4, 5);
        List<Integer> doubled = listSetter.modify(x -> x * 2, numbers);
        System.out.println("Doubled: " + doubled);

        // === Product Batch Update ===
        Setter<Product, Double> priceSetter = Setter.fromGetSet(
            Product::price,
            (p, price) -> new Product(p.name(), price, p.stock()));

        Setter<List<Product>, Product> productsSetter = Setter.forList();

        List<Product> products = List.of(
            new Product("Laptop", 999.99, 50),
            new Product("Keyboard", 79.99, 100),
            new Product("Monitor", 299.99, 30));

        // Apply 10% discount
        List<Product> discounted = productsSetter.modify(
            product -> priceSetter.modify(price -> price * 0.9, product),
            products);

        System.out.println("Discounted prices:");
        for (Product p : discounted) {
            System.out.printf("  %s: £%.2f%n", p.name(), p.price());
        }

        // === Effectful Modification ===
        Function<String, Kind<OptionalKind.Witness, String>> validateUsername =
            username -> {
                if (username.length() >= 3 && username.matches("[a-z_]+")) {
                    return OptionalKindHelper.OPTIONAL.widen(Optional.of(username));
                } else {
                    return OptionalKindHelper.OPTIONAL.widen(Optional.empty());
                }
            };

        User validUser = new User("john_doe", "john@example.com", 10, settings);
        Kind<OptionalKind.Witness, User> validResult =
            usernameSetter.modifyF(validateUsername, validUser, OptionalMonad.INSTANCE);

        Optional<User> validated = OptionalKindHelper.OPTIONAL.narrow(validResult);
        System.out.println("Valid username: " + validated.map(User::username).orElse("INVALID"));

        User invalidUser = new User("ab", "a@test.com", 0, settings);
        Kind<OptionalKind.Witness, User> invalidResult =
            usernameSetter.modifyF(validateUsername, invalidUser, OptionalMonad.INSTANCE);

        Optional<User> invalidValidated = OptionalKindHelper.OPTIONAL.narrow(invalidResult);
        System.out.println("Invalid username: " + invalidValidated.map(User::username).orElse("INVALID"));

        // === Data Normalisation ===
        Setter<Product, String> nameSetter = Setter.fromGetSet(
            Product::name,
            (p, name) -> new Product(name, p.price(), p.stock()));

        Function<String, String> titleCase = name -> {
            String trimmed = name.trim();
            return trimmed.substring(0, 1).toUpperCase() + trimmed.substring(1).toLowerCase();
        };

        List<Product> rawProducts = List.of(
            new Product("  LAPTOP  ", 999.99, 50),
            new Product("keyboard", 79.99, 100),
            new Product("MONITOR", 299.99, 30));

        List<Product> normalisedProducts = productsSetter.modify(
            product -> nameSetter.modify(titleCase, product),
            rawProducts);

        System.out.println("Normalised product names:");
        for (Product p : normalisedProducts) {
            System.out.println("  - " + p.name());
        }
    }
}

Expected Output:

Normalised: john_doe
Login count: 11
Theme: dark
Doubled: [2, 4, 6, 8, 10]
Discounted prices:
  Laptop: £899.99
  Keyboard: £71.99
  Monitor: £269.99
Valid username: john_doe
Invalid username: INVALID
Normalised product names:
  - Laptop
  - Keyboard
  - Monitor

Why Setters Are Important

Setter provides a focused, write-only approach to data modification:

  • Clear intent: Explicitly write-only, preventing accidental reads
  • Composability: Chains beautifully for deep, nested modifications
  • Batch operations: Natural fit for updating collections
  • Effectful support: Integrates with validation and error handling via Applicatives
  • Type safety: Compile-time guarantees on modification paths
  • Immutability-friendly: Designed for functional, immutable data structures

By adding Setter to your optics toolkit alongside Getter, Lens, Prism, Iso, Traversal, and Fold, you gain fine-grained control over both reading and writing patterns. Use Setter when you need composable write-only access, Getter for read-only extraction, and Lens when you need both.

The key insight: Setters make modifications first-class composable operations, allowing you to build sophisticated data transformation pipelines with clarity, type safety, and clear functional intent.


Previous: Getters: Composable Read-Only Access Next: Profunctor Optics: Advanced Data Transformation