Setters: A Practical Guide
Composable Write-Only Modifications
- How to modify data structures using composable, write-only optics
- Using
@GenerateSettersto 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
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
| Aspect | Setter | Lens | Traversal |
|---|---|---|---|
| Focus | One or more elements | Exactly one element | Zero or more elements |
| Can read? | ❌ No (typically) | ✅ Yes | ✅ Yes |
| Can modify? | ✅ Yes | ✅ Yes | ✅ Yes |
| Core operations | modify, set | get, set, modify | modifyF, getAll |
| Use case | Write-only pipelines | Read-write field access | Collection 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)→UserUserSetters.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()andforMapValues()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
LinkedListwith 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