Profunctor Optics: Advanced Data Transformation
Adapting Optics to Different Data Types
- Why every optic is a profunctor, and what
contramap,map, anddimapreally adapt - The write-side asymmetry: why
contramapalone changes where reads come from but not what updates produce - Getting a fully-typed optic back: composition for nested sources,
Isofor equivalent shapes,Lens.offor hand-rolled adapters - When the raw
Optic-level operations are the right tool (one-way conversions, effectful pipelines) - When to adapt an existing optic versus creating a new one 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 an Optic<S, T, A, B>, and that shared interface carries the profunctor operations. Just as importantly, the everyday answers to "adapt this optic" are often the composition tools you already know; this page shows you which tool fits which job.
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 extends Optic<S, T, A, B>, which provides three adaptation methods:
| Operation | Signature (simplified) | Adapts |
|---|---|---|
contramap(f) | (C -> S) -> Optic<C, T, A, B> | Where reads come from |
map(g) | (T -> U) -> Optic<S, U, A, B> | What updates produce |
dimap(f, g) | (C -> S, T -> U) -> Optic<C, U, A, B> | Both at once |
Two things about this API matter in practice:
- The result is an
Optic, not aLensorTraversal. AnOpticsupportsandThenand the effectfulmodifyF, but not the convenience surface (get,set,modify). The profunctor operations shine in effectful pipelines; for everyday field access you usually want one of the typed routes below. contramapalone is asymmetric.EmployeeLenses.department().contramap(dtoToEmployee)reads from anEmployeeDto, but a modification still produces anEmployee(the original structure typeT). To get a full bridge you must alsomapthe output back, which is exactly whatdimapdoes:
Optic<EmployeeDto, EmployeeDto, String, String> departmentBridge =
EmployeeLenses.department().dimap(Adapters::dtoToEmployee, Adapters::employeeToDto);
// An Optic is driven through modifyF. Id is the no-op effect: it runs the
// update purely, with no failure, async, or accumulation behaviour attached.
EmployeeDto promoted =
ID.narrow(departmentBridge.modifyF(
dept -> Id.of("Senior " + dept), dto, IdMonad.instance()))
.value();
The profunctor operations are what make modifyF pipelines adaptable without rebuilding them: a validating update written against your domain model can be pointed at a wire DTO by supplying the two conversion functions, and nothing else changes. When you find yourself also wanting get and set on the adapted optic, that is the signal you have an isomorphism, and the next section gives you the full API back.
Getting a Typed Optic Back
The raw operations return Optic. In most day-to-day code you want a real Lens or Traversal back, and there are three idiomatic routes.
Route 1: Composition, when the new source contains the old
The most common "contramap" wish is really a nested-field access, and andThen already does it, keeping every convenience method:
// Employee contains a Person; compose instead of adapting
Lens<Employee, String> employeeFirstName =
EmployeeLenses.personalInfo().andThen(PersonLenses.firstName());
String name = employeeFirstName.get(employee);
Employee shouted = employeeFirstName.modify(String::toUpperCase, employee);
Route 2: An Iso, when the two shapes hold the same information
If your conversion functions form a lossless pair, they are an Iso, and composing with one keeps the whole API in either direction (Lens >>> Iso = Lens, and Iso >>> Lens = Lens). This is dimap with the power retained:
// UserId is a wrapper around String: a textbook Iso
Iso<UserId, String> userIdValue = Iso.of(UserId::value, UserId::new);
// Reuse any String-side optic against the wrapper
Lens<Account, UserId> accountUser = AccountLenses.userId();
Lens<Account, String> accountUserRaw = accountUser.andThen(userIdValue);
Account renamed = accountUserRaw.modify(String::toUpperCase, account);
Route 3: Lens.of, when the adaptation is genuinely one-off
When neither composition nor an Iso fits (the conversion is lopsided, or you only need one field bridged), build the adapted lens directly. This is the "profunctor-style" adaptation the runnable example demonstrates:
Lens<Employee, String> employeeFirstNameLens =
Lens.of(
employee -> PersonLenses.firstName().get(employee.personalInfo()),
(employee, newName) ->
new Employee(
employee.id(),
PersonLenses.firstName().set(newName, employee.personalInfo()),
employee.department()));
Decision Guide: Which Adaptation Do You Need?
flowchart TD
Q{"How do the two<br/>types relate?"}
Q -->|"new source contains<br/>the old as a field"| C(["Compose with andThen"])
Q -->|"same information,<br/>two lossless shapes"| I(["Iso, then compose"])
Q -->|"one-way conversion,<br/>effectful modifyF pipeline"| D(["Optic contramap / map / dimap"])
Q -->|"anything lopsided<br/>or one-off"| L(["Hand-build with Lens.of"])
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
class Q decision
class C,I,D,L tier
Common Pitfalls
Don't Do This:
// Rebuilding the same adapter inline repeatedly
var lens1 = EmployeeLenses.personalInfo().andThen(PersonLenses.firstName());
var lens2 = EmployeeLenses.personalInfo().andThen(PersonLenses.firstName());
// Expecting contramap alone to give a full bridge
// (reads take a DTO, but updates still produce an Employee)
var readOnlyBridge = EmployeeLenses.department().contramap(Adapters::dtoToEmployee);
// Pretending a lossy conversion is an Iso
Iso<PersonDto, Person> lossy = Iso.of(
dto -> new Person(dto.fullName().split(" ")[0], "", null, List.of()),
person -> new PersonDto(person.firstName(), "", List.of())); // Round trip loses data!
Do This Instead:
// Create adapters once, reuse everywhere
public static final Lens<Employee, String> EMPLOYEE_FIRST_NAME =
EmployeeLenses.personalInfo().andThen(PersonLenses.firstName());
// Use dimap when you need the bridge, and drive it through modifyF
public static final Optic<EmployeeDto, EmployeeDto, String, String> DEPARTMENT_BRIDGE =
EmployeeLenses.department().dimap(Adapters::dtoToEmployee, Adapters::employeeToDto);
// Reserve Iso for genuinely lossless pairs (wrappers, equivalent records)
public static final Iso<UserId, String> USER_ID_VALUE = Iso.of(UserId::value, UserId::new);
Iso.of(get, reverseGet) promises a lossless round trip, and the IsoLaws law harness will hold you to it. A DTO conversion that drops or invents fields is not an Iso; route it through @GenerateMapping or a Validated Prism instead.
Performance Notes
- Adapters are thin: each operation wraps the underlying optic with the conversion functions; there is no reflection and no copying beyond what the conversions themselves do
- Reuse beats rebuilding: store composed or adapted optics as constants, the same discipline as every other page
- Conversion cost is your cost: a
dimapbridge runs your two functions on every pass, so keep them cheap and total
Real-World Example: API Integration
The runnable OpticProfunctorExample walks a complete integration: an internal Employee/Person model, an external EmployeeDto/PersonDto wire format, and adapters hand-built with Lens.of (Route 3), including a formatted-date bridge that reads through a formatter and writes back through a parser, plus a conversion pair driven through modifyF. It compiles and runs on every build, so it is the reference when you wire your own.
The shape to copy:
public class ApiIntegration {
// One pair of conversion utilities, written once and tested
static Employee dtoToEmployee(EmployeeDto dto) { /* ... */ }
static EmployeeDto employeeToDto(Employee employee) { /* ... */ }
// Typed optics for everyday access, via composition
public static final Lens<Employee, String> FIRST_NAME =
EmployeeLenses.personalInfo().andThen(PersonLenses.firstName());
// A dimap bridge where the pipeline is effectful anyway
public static final Optic<EmployeeDto, EmployeeDto, String, String> DEPARTMENT =
EmployeeLenses.department().dimap(
ApiIntegration::dtoToEmployee,
ApiIntegration::employeeToDto);
}
- Every optic is an
Optic<S, T, A, B>:contramap,map, anddimaplive there, adapting themodifyFpipeline and returning anOptic contramapalone is read-side only: updates still produce the original structure type; a full bridge needsdimap- Composition is the everyday "contramap": when the new source contains the old,
andThenkeeps the full typed API - A lossless pair is an
Iso: compose it and keepget/set/modify; that isdimapwith the power retained Lens.ofis the honest fallback: hand-build the adapter when the relationship is lopsided or one-off
- Profunctor Optics: Recipes: wrapper-type recipes, V1/V2 migration adapters, and a complete runnable example
- Isomorphisms: the lossless conversions that keep the full optic API
- Composition Rules: what
andThenyields for every optic pairing
Previous: Advanced Prism Patterns: Recipes Next: Profunctor Optics: Recipes