Profunctor Optics: Advanced Data Transformation
Adapting Optics to Different Data Types
- How to adapt existing optics to work with different data types
- Using
contramap
to change source types andmap
to change target types - Combining both adaptations with
dimap
for complete format conversion - Creating reusable adapter patterns for API integration
- Working with type-safe wrapper classes and legacy system integration
- When to use profunctor adaptations vs creating new optics from scratch
In the previous optics guides, we explored how to work with data structures directly using Lens
, Prism
, Iso
, and Traversal
. But what happens when you need to use an optic designed for one data type with a completely different data structure? What if you want to adapt an existing optic to work with new input or output formats?
This is where the profunctor nature of optics becomes invaluable. Every optic in higher-kinded-j is fundamentally a profunctor, which means it can be adapted to work with different source and target types using powerful transformation operations.
The Challenge: Type Mismatch in Real Systems
In real-world applications, you frequently encounter situations where:
- Legacy Integration: You have optics designed for old data structures but need to work with new ones
- API Adaptation: External APIs use different field names or data formats than your internal models
- Type Safety: You want to work with strongly-typed wrapper classes but reuse optics designed for raw values
- Data Migration: You're transitioning between data formats and need optics that work with both
Consider this scenario: you have a well-tested Lens
that operates on a Person
record, but you need to use it with an Employee
record that contains a Person
as a nested field. Rather than rewriting the lens, you can adapt it.
Think of Profunctor Adaptations Like...
- Universal adapters: Like electrical plug adapters that make devices work in different countries
- Translation layers: Converting between different "languages" of data representation
- Lens filters: Modifying what the optic sees (input) and what it produces (output)
- Pipeline adapters: Connecting optics that weren't originally designed to work together
The Three Profunctor Operations
Every optic provides three powerful adaptation methods that mirror the core profunctor operations:
1. contramap
: Adapting the Source Type
The contramap
operation allows you to adapt an optic to work with a different source type by providing a function that converts from the new source to the original source.
Use Case: You have a Lens<Person, String>
for getting a person's first name, but you want to use it with Employee
objects.
// Original lens: Person -> String (first name)
Lens<Person, String> firstNameLens = PersonLenses.firstName();
// Adapt it to work with Employee by providing the conversion
Lens<Employee, String> employeeFirstNameLens =
firstNameLens.contramap(employee -> employee.personalInfo());
// Now you can use the adapted lens directly on Employee objects
Employee employee = new Employee(123, new Person("Alice", "Johnson", ...), "Engineering");
String firstName = employeeFirstNameLens.get(employee); // "Alice"
2. map
: Adapting the Target Type
The map
operation adapts an optic to work with a different target type by providing a function that converts from the original target to the new target.
Use Case: You have a Lens<Person, LocalDate>
for birth dates, but you want to work with formatted strings instead.
// Original lens: Person -> LocalDate
Lens<Person, LocalDate> birthDateLens = PersonLenses.birthDate();
// Adapt it to work with formatted strings
Lens<Person, String> birthDateStringLens =
birthDateLens.map(date -> date.format(DateTimeFormatter.ISO_LOCAL_DATE));
// The adapted lens now returns strings
Person person = new Person("Bob", "Smith", LocalDate.of(1985, 12, 25), ...);
String dateString = birthDateStringLens.get(person); // "1985-12-25"
3. dimap
: Adapting Both Source and Target Types
The dimap
operation is the most powerful—it adapts both the source and target types simultaneously. This is perfect for converting between completely different data representations.
Use Case: You have optics designed for internal Person
objects but need to work with external PersonDto
objects that use different field structures.
// Original traversal: Person -> String (hobbies)
Traversal<Person, String> hobbiesTraversal = PersonTraversals.hobbies();
// Adapt it to work with PersonDto (different source) and call them "interests" (different context)
Traversal<PersonDto, String> interestsTraversal =
hobbiesTraversal.dimap(
// Convert PersonDto to Person
dto -> new Person(
dto.fullName().split(" ")[0],
dto.fullName().split(" ")[1],
LocalDate.parse(dto.birthDateString()),
dto.interests()
),
// Convert Person back to PersonDto
person -> new PersonDto(
person.firstName() + " " + person.lastName(),
person.birthDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
person.hobbies()
)
);
Decision Guide: When to Use Each Operation
Use contramap
When:
- Different source type, same target - Existing optic works perfectly, just need different input
- Extracting nested data - Your new type contains the old type as a field
- Wrapper type handling - Working with strongly-typed wrappers around base types
java
// Perfect for extracting nested data
Lens<Order, String> customerNameLens =
OrderLenses.customer().contramap(invoice -> invoice.order());
Use map
When:
- Same source, different target format - You want to transform the output
- Data presentation - Converting raw data to display formats
- Type strengthening - Wrapping raw values in type-safe containers
java
// Perfect for presentation formatting
Lens<Product, String> formattedPriceLens =
ProductLenses.price().map(price -> "£" + price.setScale(2));
Use dimap
When:
- Complete format conversion - Both input and output need transformation
- API integration - External systems use completely different data structures
- Legacy system support - Bridging between old and new data formats
- Data migration - Supporting multiple data representations simultaneously
java
// Perfect for API integration
Traversal<ApiUserDto, String> apiRolesTraversal =
UserTraversals.roles().dimap(
dto -> convertApiDtoToUser(dto),
user -> convertUserToApiDto(user)
);
Common Pitfalls
❌ Don't Do This:
// Creating adapters inline repeatedly
var lens1 = PersonLenses.firstName().contramap(emp -> emp.person());
var lens2 = PersonLenses.firstName().contramap(emp -> emp.person());
var lens3 = PersonLenses.firstName().contramap(emp -> emp.person());
// Over-adapting simple cases
Lens<Person, String> nameUpper = PersonLenses.firstName()
.map(String::toUpperCase)
.map(s -> s.trim())
.map(s -> s.replace(" ", "_")); // Just write one function!
// Forgetting null safety in conversions
Lens<EmployeeDto, String> unsafeLens = PersonLenses.firstName()
.contramap(dto -> dto.person()); // What if dto.person() is null?
// Complex conversions without error handling
Traversal<String, LocalDate> fragileParser =
Iso.of(LocalDate::toString, LocalDate::parse).asTraversal()
.contramap(complexString -> extractDatePart(complexString)); // Might throw!
✅ Do This Instead:
// Create adapters once, reuse everywhere
public static final Lens<Employee, String> EMPLOYEE_FIRST_NAME =
PersonLenses.firstName().contramap(Employee::personalInfo);
// Combine transformations efficiently
Function<String, String> normalise = name ->
name.toUpperCase().trim().replace(" ", "_");
Lens<Person, String> normalisedNameLens = PersonLenses.firstName().map(normalise);
// Handle null safety explicitly
Lens<EmployeeDto, Optional<String>> safeNameLens = PersonLenses.firstName()
.contramap((EmployeeDto dto) -> Optional.ofNullable(dto.person()))
.map(Optional::of);
// Use safe conversions with proper error handling
Function<String, Either<String, LocalDate>> safeParse = str -> {
try {
return Either.right(LocalDate.parse(extractDatePart(str)));
} catch (Exception e) {
return Either.left("Invalid date: " + str);
}
};
Performance Notes
Profunctor adaptations are designed for efficiency:
- Automatic fusion: Multiple
contramap
ormap
operations are automatically combined - Lazy evaluation: Conversions only happen when the optic is actually used
- No boxing overhead: Simple transformations are inlined by the JVM
- Reusable adapters: Create once, use many times without additional overhead
Best Practice: Create adapted optics as constants and reuse them:
public class OpticAdapters {
// Create once, use everywhere
public static final Lens<Employee, String> FIRST_NAME =
PersonLenses.firstName().contramap(Employee::personalInfo);
public static final Lens<Employee, String> FORMATTED_BIRTH_DATE =
PersonLenses.birthDate()
.contramap(Employee::personalInfo)
.map(date -> date.format(DateTimeFormatter.DD_MM_YYYY));
public static final Traversal<CompanyDto, String> EMPLOYEE_EMAILS =
CompanyTraversals.employees()
.contramap((CompanyDto dto) -> convertDtoToCompany(dto))
.andThen(EmployeeTraversals.contacts())
.andThen(ContactLenses.email().asTraversal());
}
Real-World Example: API Integration
Let's explore a comprehensive example where you need to integrate with an external API that uses different field names and data structures than your internal models.
The Scenario: Your internal system uses Employee
records, but the external API expects EmployeeDto
objects with different field names:
// Internal model
@GenerateLenses
@GenerateTraversals
public record Employee(int id, Person personalInfo, String department) {}
@GenerateLenses
@GenerateTraversals
public record Person(String firstName, String lastName, LocalDate birthDate, List<String> skills) {}
// External API model
@GenerateLenses
public record EmployeeDto(int employeeId, PersonDto person, String dept) {}
@GenerateLenses
public record PersonDto(String fullName, String birthDateString, List<String> expertise) {}
The Solution: Create an adapter that converts between these formats while reusing your existing optics:
public class ApiIntegration {
// Conversion utilities
private static Employee dtoToEmployee(EmployeeDto dto) {
PersonDto personDto = dto.person();
String[] nameParts = personDto.fullName().split(" ", 2);
Person person = new Person(
nameParts[0],
nameParts.length > 1 ? nameParts[1] : "",
LocalDate.parse(personDto.birthDateString()),
personDto.expertise()
);
return new Employee(dto.employeeId(), person, dto.dept());
}
private static EmployeeDto employeeToDto(Employee employee) {
Person person = employee.personalInfo();
PersonDto personDto = new PersonDto(
person.firstName() + " " + person.lastName(),
person.birthDate().toString(),
person.skills()
);
return new EmployeeDto(employee.id(), personDto, employee.department());
}
// Adapted optics for API integration
public static final Lens<EmployeeDto, String> API_EMPLOYEE_DEPARTMENT =
EmployeeLenses.department().dimap(
ApiIntegration::dtoToEmployee,
ApiIntegration::employeeToDto
);
public static final Lens<EmployeeDto, String> API_EMPLOYEE_FIRST_NAME =
EmployeeLenses.personalInfo()
.andThen(PersonLenses.firstName())
.dimap(
ApiIntegration::dtoToEmployee,
ApiIntegration::employeeToDto
);
public static final Traversal<EmployeeDto, String> API_EMPLOYEE_SKILLS =
EmployeeTraversals.personalInfo()
.andThen(PersonTraversals.skills())
.dimap(
ApiIntegration::dtoToEmployee,
ApiIntegration::employeeToDto
);
// Use the adapters seamlessly with external data
public void processApiData(EmployeeDto externalEmployee) {
// Update department using existing business logic
EmployeeDto promoted = API_EMPLOYEE_DEPARTMENT.modify(
dept -> "Senior " + dept,
externalEmployee
);
// Normalise skills using existing traversal logic
EmployeeDto normalisedSkills = Traversals.modify(
API_EMPLOYEE_SKILLS,
skill -> skill.toLowerCase().trim(),
externalEmployee
);
sendToApi(promoted);
sendToApi(normalisedSkills);
}
}
Working with Type-Safe Wrappers
Another powerful use case is adapting optics to work with strongly-typed wrapper classes while maintaining type safety.
The Challenge: You want to use string manipulation functions on wrapper types:
// Strongly-typed wrappers
public record UserId(String value) {}
public record UserName(String value) {}
public record Email(String value) {}
@GenerateLenses
public record User(UserId id, UserName name, Email email, LocalDate createdAt) {}
The Solution: Create adapted lenses that unwrap and rewrap values:
public class WrapperAdapters {
// Generic wrapper lens creator
public static <W> Lens<W, String> stringWrapperLens(
Function<W, String> unwrap,
Function<String, W> wrap
) {
return Lens.of(unwrap, (wrapper, newValue) -> wrap.apply(newValue));
}
// Specific wrapper lenses
public static final Lens<UserId, String> USER_ID_STRING =
stringWrapperLens(UserId::value, UserId::new);
public static final Lens<UserName, String> USER_NAME_STRING =
stringWrapperLens(UserName::value, UserName::new);
public static final Lens<Email, String> EMAIL_STRING =
stringWrapperLens(Email::value, Email::new);
// Composed lenses for User operations
public static final Lens<User, String> USER_NAME_VALUE =
UserLenses.name().andThen(USER_NAME_STRING);
public static final Lens<User, String> USER_EMAIL_VALUE =
UserLenses.email().andThen(EMAIL_STRING);
// Usage examples
public User normaliseUser(User user) {
return USER_NAME_VALUE.modify(name ->
Arrays.stream(name.toLowerCase().split(" "))
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
.collect(joining(" ")),
user
);
}
public User updateEmailDomain(User user, String newDomain) {
return USER_EMAIL_VALUE.modify(email -> {
String localPart = email.substring(0, email.indexOf('@'));
return localPart + "@" + newDomain;
}, user);
}
}
Migration Patterns
Profunctor adaptations are particularly valuable during system migrations:
Legacy System Integration
// You have optics for PersonV1, but data is now PersonV2
public record PersonV1(String name, int age) {}
@GenerateLenses
public record PersonV2(String firstName, String lastName, LocalDate birthDate) {}
public class MigrationAdapters {
// Convert between versions
private static PersonV1 v2ToV1(PersonV2 v2) {
return new PersonV1(
v2.firstName() + " " + v2.lastName(),
Period.between(v2.birthDate(), LocalDate.now()).getYears()
);
}
private static PersonV2 v1ToV2(PersonV1 v1) {
String[] nameParts = v1.name().split(" ", 2);
return new PersonV2(
nameParts[0],
nameParts.length > 1 ? nameParts[1] : "",
LocalDate.now().minusYears(v1.age())
);
}
// Existing V1 optics work with V2 data
public static final Lens<PersonV2, String> V2_NAME_FROM_V1_LENS =
// Assume we have a V1 name lens
Lens.of(PersonV1::name, (p1, name) -> new PersonV1(name, p1.age()))
.dimap(MigrationAdapters::v2ToV1, MigrationAdapters::v1ToV2);
}
Database Schema Evolution
// Old database entity
public record CustomerEntityV1(Long id, String name, String email) {}
// New database entity
@GenerateLenses
public record CustomerEntityV2(Long id, String firstName, String lastName, String emailAddress, boolean active) {}
public class SchemaAdapters {
// Adapter for name field
public static final Lens<CustomerEntityV2, String> FULL_NAME_ADAPTER =
Lens.of(CustomerEntityV1::name, (v1, name) -> new CustomerEntityV1(v1.id(), name, v1.email()))
.dimap(
// V2 -> V1 conversion
v2 -> new CustomerEntityV1(v2.id(), v2.firstName() + " " + v2.lastName(), v2.emailAddress()),
// V1 -> V2 conversion
v1 -> {
String[] parts = v1.name().split(" ", 2);
return new CustomerEntityV2(
v1.id(),
parts[0],
parts.length > 1 ? parts[1] : "",
v1.email(),
true // Default active status
);
}
);
}
Complete, Runnable Example
This comprehensive example demonstrates all three profunctor operations in a realistic scenario:
package org.higherkindedj.example.optics.profunctor;
import org.higherkindedj.optics.Lens;
import org.higherkindedj.optics.Traversal;
import org.higherkindedj.optics.annotations.GenerateLenses;
import org.higherkindedj.optics.annotations.GenerateTraversals;
import org.higherkindedj.optics.util.Traversals;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
public class OpticProfunctorExample {
// Internal data model
@GenerateLenses
@GenerateTraversals
public record Person(String firstName, String lastName, LocalDate birthDate, List<String> hobbies) {}
@GenerateLenses
public record Employee(int id, Person personalInfo, String department) {}
// External API model
@GenerateLenses
public record PersonDto(String fullName, String birthDateString, List<String> interests) {}
@GenerateLenses
public record EmployeeDto(int employeeId, PersonDto person, String dept) {}
// Type-safe wrapper
public record UserId(long value) {}
@GenerateLenses
public record UserProfile(UserId id, String displayName, boolean active) {}
public static void main(String[] args) {
System.out.println("=== PROFUNCTOR OPTICS EXAMPLE ===");
// Test data
var person = new Person("Alice", "Johnson",
LocalDate.of(1985, 6, 15),
List.of("reading", "cycling", "photography"));
var employee = new Employee(123, person, "Engineering");
// --- SCENARIO 1: contramap - Adapt source type ---
System.out.println("--- Scenario 1: contramap (Source Adaptation) ---");
// Original lens works on Person, adapt it for Employee
Lens<Person, String> firstNameLens = PersonLenses.firstName();
Lens<Employee, String> employeeFirstNameLens =
firstNameLens.contramap(Employee::personalInfo);
String name = employeeFirstNameLens.get(employee);
Employee renamedEmployee = employeeFirstNameLens.set("Alicia", employee);
System.out.println("Original employee: " + employee);
System.out.println("Extracted name: " + name);
System.out.println("Renamed employee: " + renamedEmployee);
System.out.println();
// --- SCENARIO 2: map - Adapt target type ---
System.out.println("--- Scenario 2: map (Target Adaptation) ---");
// Original lens returns LocalDate, adapt it to return formatted string
Lens<Person, LocalDate> birthDateLens = PersonLenses.birthDate();
Lens<Person, String> birthDateStringLens =
birthDateLens.map(date -> date.format(DateTimeFormatter.ISO_LOCAL_DATE));
String formattedDate = birthDateStringLens.get(person);
// Note: set operation would need to parse the string back to LocalDate
System.out.println("Person: " + person);
System.out.println("Formatted birth date: " + formattedDate);
System.out.println();
// --- SCENARIO 3: dimap - Adapt both source and target ---
System.out.println("--- Scenario 3: dimap (Both Source and Target Adaptation) ---");
// Convert between internal Person and external PersonDto
Traversal<Person, String> hobbiesTraversal = PersonTraversals.hobbies();
Traversal<PersonDto, String> interestsTraversal = hobbiesTraversal.dimap(
// PersonDto -> Person
dto -> {
String[] nameParts = dto.fullName().split(" ", 2);
return new Person(
nameParts[0],
nameParts.length > 1 ? nameParts[1] : "",
LocalDate.parse(dto.birthDateString()),
dto.interests()
);
},
// Person -> PersonDto
p -> new PersonDto(
p.firstName() + " " + p.lastName(),
p.birthDate().toString(),
p.hobbies()
)
);
var personDto = new PersonDto("Bob Smith", "1990-03-20",
List.of("gaming", "cooking", "travel"));
List<String> extractedInterests = Traversals.getAll(interestsTraversal, personDto);
PersonDto updatedDto = Traversals.modify(interestsTraversal,
interest -> interest.toUpperCase(), personDto);
System.out.println("Original DTO: " + personDto);
System.out.println("Extracted interests: " + extractedInterests);
System.out.println("Updated DTO: " + updatedDto);
System.out.println();
// --- SCENARIO 4: Working with wrapper types ---
System.out.println("--- Scenario 4: Wrapper Type Integration ---");
// Create a lens that works directly with the wrapped value
Lens<UserId, Long> userIdValueLens = Lens.of(UserId::value, (id, newValue) -> new UserId(newValue));
Lens<UserProfile, Long> profileIdValueLens =
UserProfileLenses.id().andThen(userIdValueLens);
var userProfile = new UserProfile(new UserId(456L), "Alice J.", true);
Long idValue = profileIdValueLens.get(userProfile);
UserProfile updatedProfile = profileIdValueLens.modify(id -> id + 1000, userProfile);
System.out.println("Original profile: " + userProfile);
System.out.println("Extracted ID value: " + idValue);
System.out.println("Updated profile: " + updatedProfile);
System.out.println();
// --- SCENARIO 5: Chaining adaptations ---
System.out.println("--- Scenario 5: Chaining Adaptations ---");
// Chain multiple adaptations: Employee -> Person -> String (formatted)
Lens<Employee, String> formattedEmployeeName =
PersonLenses.firstName()
.contramap(Employee::personalInfo) // Employee -> Person
.map(name -> "Mr/Ms. " + name.toUpperCase()); // String -> Formatted String
String formalName = formattedEmployeeName.get(employee);
Employee formalEmployee = formattedEmployeeName.set("Mr/Ms. ROBERT", employee);
System.out.println("Original employee: " + employee);
System.out.println("Formal name: " + formalName);
System.out.println("Employee with formal name: " + formalEmployee);
System.out.println();
// --- SCENARIO 6: Safe adaptations with Optional ---
System.out.println("--- Scenario 6: Safe Adaptations ---");
// Handle potentially null fields safely
Lens<Optional<Person>, Optional<String>> safeNameLens =
PersonLenses.firstName()
.map(Optional::of)
.contramap(optPerson -> optPerson.orElse(new Person("", "", LocalDate.now(), List.of())));
Optional<Person> maybePerson = Optional.of(person);
Optional<Person> emptyPerson = Optional.empty();
Optional<String> safeName1 = safeNameLens.get(maybePerson);
Optional<String> safeName2 = safeNameLens.get(emptyPerson);
System.out.println("Safe name from present person: " + safeName1);
System.out.println("Safe name from empty person: " + safeName2);
}
}
Expected Output:
=== PROFUNCTOR OPTICS EXAMPLE ===
--- Scenario 1: contramap (Source Adaptation) ---
Original employee: Employee[id=123, personalInfo=Person[firstName=Alice, lastName=Johnson, birthDate=1985-06-15, hobbies=[reading, cycling, photography]], department=Engineering]
Extracted name: Alice
Renamed employee: Employee[id=123, personalInfo=Person[firstName=Alicia, lastName=Johnson, birthDate=1985-06-15, hobbies=[reading, cycling, photography]], department=Engineering]
--- Scenario 2: map (Target Adaptation) ---
Person: Person[firstName=Alice, lastName=Johnson, birthDate=1985-06-15, hobbies=[reading, cycling, photography]]
Formatted birth date: 1985-06-15
--- Scenario 3: dimap (Both Source and Target Adaptation) ---
Original DTO: PersonDto[fullName=Bob Smith, birthDateString=1990-03-20, interests=[gaming, cooking, travel]]
Extracted interests: [gaming, cooking, travel]
Updated DTO: PersonDto[fullName=Bob Smith, birthDateString=1990-03-20, interests=[GAMING, COOKING, TRAVEL]]
--- Scenario 4: Wrapper Type Integration ---
Original profile: UserProfile[id=UserId[value=456], displayName=Alice J., active=true]
Extracted ID value: 456
Updated profile: UserProfile[id=UserId[value=1456], displayName=Alice J., active=true]
--- Scenario 5: Chaining Adaptations ---
Original employee: Employee[id=123, personalInfo=Person[firstName=Alice, lastName=Johnson, birthDate=1985-06-15, hobbies=[reading, cycling, photography]], department=Engineering]
Formal name: Mr/Ms. ALICE
Employee with formal name: Employee[id=123, personalInfo=Person[firstName=ROBERT, lastName=Johnson, birthDate=1985-06-15, hobbies=[reading, cycling, photography]], department=Engineering]
--- Scenario 6: Safe Adaptations ---
Safe name from present person: Optional[Alice]
Safe name from empty person: Optional[]
Integration with Existing Optics
Profunctor adaptations work seamlessly with all the optic types and features you've already learned:
With Effectful Updates
// Original effectful lens
Lens<Person, String> emailLens = PersonLenses.email();
// Adapt it for Employee and use with validation
Lens<Employee, String> employeeEmailLens = emailLens.contramap(Employee::personalInfo);
// Use with effectful validation as normal
Kind<ValidatedKind.Witness<String>, Employee> result =
employeeEmailLens.modifyF(this::validateEmail, employee, validatedApplicative);
With Deep Composition
// Compose adapted optics just like regular optics
Traversal<EmployeeDto, String> deepPath =
apiAdapter.asTraversal()
.andThen(PersonTraversals.hobbies())
.andThen(stringProcessor);
This profunctor capability makes higher-kinded-j optics incredibly flexible and reusable, allowing you to adapt existing, well-tested optics to work with new data formats and requirements without rewriting your core business logic.
Previous:Traversals: Handling Bulk Updates Next:Capstone Example: Deep Validation