Skip to main content
  1. Posts/

An Introduction to Profunctor Optics

·9 mins· loading · loading ·
William Rågstad
Author
William Rågstad
Computer science @ KTH in Sweden.
Understanding - This article is part of a series.
Part 4: This Article

Introduction
#

For some time now, I’ve been interested in learning more about category theory and its applications in functional programming. Recently, I came across the concept of profunctor optics, which I found to be super powerful abstractions for manipulating data structures in a composable way. I’ll admit that understanding profunctor optics was quite the challenge for me, especially when trying to grasp the underlying category theory concepts behind them and decode the dense academic notation used to explain them. So that’s why I decided to write this post, to share my journey of understanding profunctor optics and why they are useful.

To understand the following concepts, we first need to cover some basic category-theoretic notation, terminology, and fundamental ideas.

Morphisms
#

Briefly put, the morphisms $f$ and $g$ are structure-preserving mappings between two objects in the categories $A$ to $B$ and $B$ to $C$, respectively. Together, they can be composed to form a new morphism $g \circ f$ that map directly from $A$ to $C$ as shown in the diagram below:

Morphisms

A morphism is a function in a program that map values of one type to another. For example, consider a function to_string taking an $Int$ and returning a $String$.

let x: Int = 42;
let s: String = to_string(x);

$$ Int \xrightarrow{\quad\text{to\_string}\quad} String $$

Functors
#

A functor $F$ is a mapping between categories that preserves the structure of the categories, meaning it maps objects to objects and morphisms to morphisms in a way that respects composition and identity. This means that for any two morphisms $f: A \rarr B$ and $g: B \rarr C$, the functor $F$ obey the following rules:

  1. Composition Preservation: $F(g \circ f) = F(g) \circ F(f)$
  2. Identity Preservation: $F(id_A) = id_{F(A)}$

There are also two properties of functors regarding how they map objects and morphisms between the source and target categories:

  • Object Mapping: For every object $A$ there is a corresponding object $F(A)$.
  • Morphism Mapping: For every morphism $f: A \rarr B$ there is a corresponding morphism $F_f = F(f) = \text{fmap}_F(f)$, often called map.

$$ \begin{align*} & f: A \rarr B \\ & \Downarrow \\ \text{fmap}_{F}(f) = \ & F(f) \ : \ F(A) \rarr F(B) \end{align*} $$

These can be visualized by the following diagram:

Abstract Functors

Notice how every object and morphism is mapped to a corresponding object and morphism in the target category, while preserving the composition of morphisms. As a concrete example, an optional type Maybe/Option can be seen as a functor that maps a type $A$ to $Option(A)$, and a function $f: A \rarr B$ to a function $\text{fmap}(f)$ (or map), defining functor morphisms:

$$ \text{fmap}_{Option}(f) \ : \ Option(A) \rarr Option(B) $$

let x: Option<Int> = Some(42);
let s: Option<String> = x.map(to_string);

As seen in the code above, .map(f) applies the function to_string to the value inside Option without changing the container structure. A map/fmap method must exist for all functor types such as Maybe/Option, Result/Either, List/Vec/Array, etc. The mapping we did above can be visualized as a functor in the following commutative diagram which represents how a functor $Option$ preserves the structure of the morphism/function $\text{to\_string}$:

Option Functor

Profunctors
#

You might have seen some attempts to summarize the whole idea of profunctors in a single sentence, similar to:

“A profunctor is just a bifunctor that is contravariant in its first argument and covariant in its second.”

Which, frankly, doesn’t help much if you don’t already know what a bifunctor is or what covariance and contravariance mean. So, let me instead examine what it can do for us by looking more closely at its mapping properties. For functors, we saw how they map a single morphism $f: A \rarr B$ to another $F(f): F(A) \rarr F(B)$, otherwise known as $\text{fmap}_F(f)$.
A Profunctor $P$ on the other hand is slightly more complex as it deals with two morphisms simultaneously, usually performing mappings using both $f$ and $g$ at once via $\text{dimap}_P(f, g)$. The Greek prefix di- is a shortened form of dis, meaning “two, double, twice, twofold”.

$$ \begin{align*} & f: A \rarr B \\ & g: A’ \rarr B’ \\ & \Downarrow \\ \text{dimap}_{P}(f, g) = \ & P(f, g) \ : \ P(A, B) \rarr P(A’, B’) \end{align*} $$

Profunctor

What Does Optics Mean?
#

In everyday programming, we constantly perform a variation of reading a value from within a larger structure, updating that value while keeping the rest of the structure intact, and composing such transformations to build bigger ones.

An optic is a small, reusable abstraction that packages this idea of focusing on some part(s) of a structure. Concretely, an optic describes a relationship between a whole structure $S$ (and possibly an updated structure $T$) and a focus inside it $A$ (and possibly an updated focus $B$).

You will often see this written as $Optic\ S\ T\ A\ B$, which you can read as:

“An optic lets me find an $A$ inside an $S$, and if I can turn that $A$ into a $B$, then I can turn the whole $S$ into a $T$.”

Different optics correspond to different shapes of data: Lenses focus on exactly one part of a product-like structure (structs/tuples), prisms focus on at most one part of a sum-like structure (enums/variants), and traversals focus on zero or more parts inside containers (lists, trees, nested structures).

Lenses
#

A lens focuses on exactly one component that is always present (a product-like “field inside a struct”). Operationally, it’s just a get/read function $view : S \to A$, update $set : S \to B \to T$, and modify $over : (A \to B) \to S \to T$ can be derived for convenience as seen below. The function $over\ f\ s$ means “apply $f$ to the focused part of $s$ and rebuild it”. The three Lens laws are summarized in the table below:

fn view(s: S) -> A;
fn set(s: S, b: B) -> T;
fn over(f: (A -> B), s: S) -> T {
    let a = view(s);
    let b = f(a);
    return set(s, b);
}
LawEquation
Get-Put$set\ s\ (view\ s)=s$
Put-Get$view\ (set\ s\ b)=b$
Put-Put$set\ (set\ s\ b)\ c=set\ s\ c$

For example, if $S$ is a User record and $A$ is the email field, then a lens for email lets you read and update the email without caring about the rest of the fields.

Prisms
#

A prism focuses on at most one payload inside a sum/choice (an enum variant). Operationally, it’s just a matcher $preview : S \to Option\ A$ and builder $review : B \to T$. The two prism laws are summarized in the table below:

fn preview(s: S) -> Option<A>;
fn review(b: B) -> T;
LawEquation
Build-Match$preview\ (review\ b)\newline =Some\ b$
Match-Build$preview\ s=Some\ a\newline \Rightarrow\ review\ a=s$

For example, in a sum type like Result<A, E>, a prism for the Ok case can extract the A if it is present, and can also build a new Ok from a value.

Traversals
#

A traversal focuses on zero or more parts (“all elements in a list”, “all leaves in a tree”, etc.). Traversals generalize the idea of a functor $\text{fmap}_F$, but through a structure you don’t want to manually recurse through. Operationally, $traverse : (A \to F\ B) \to S \to F\ T$ means given a function $f : A \to F\ B$, it transforms a whole $S$ into $F\ T$ by visiting every focus, applying an effectful function, and rebuilding the structure inside the applicative functor $F$.

fn traverse(f: (A -> F<B>), s: S) -> F<T>;
LawEquation
Identity$traverse\ (Id\ \circ\ f)\ s\newline = Id\ (over\ f\ s)$
Composition$traverse\ (fmap_F\ g \circ f)\ s\newline = fmap_F\newline\quad (traverse\ g)\newline\quad (traverse\ f\ s)$

No matter if you choose $F$ to (1) collect logs, (2) short-circuit, or (3) accumulate errors, the same traversal structure does that uniformly while still rebuilding the final structure $T$. In practice, the laws are what justify treating traversals as a principled abstraction rather than a fancy loop.

Profunctor Optics
#

So far, we have described lenses, prisms, and traversals in terms of the operations they support. That is useful, but it has a downside: each optic type tends to have its own representation and composition story.

Profunctor optics solve this by giving a single representation for many optic kinds. Instead of saying “a lens is a getter + setter”, we say:

“An optic is something that transforms one profunctor into another.”

The standard profunctor optic encoding looks like this (often shown in Haskell-like notation):

$$ Optic\ S\ T\ A\ B ;\cong; \forall p.; C\ p \Rightarrow p\ A\ B \to p\ S\ T $$

Read it as: pick a profunctor $p$ (that supports some capability $C$), assume you can transform $A$ to $B$ inside $p$ (that is $p\ A\ B$), and then the optic tells you how to get a transformation from $S$ to $T$ inside the same $p$.

This is where the earlier dimap becomes relevant: the optic is essentially a structured way of pre- and post-processing a transformation. It “routes” the transformation through the larger structure.

Different optic kinds correspond to different additional capabilities on $p$: A lens corresponds to profunctors that can move through products (pairs/structs), which is usually called Strong. A prism corresponds to profunctors that can move through sums (either/enums), which is usually called Choice. A traversal corresponds to profunctors that can move through many elements in a structure, which is often packaged as Wander.

You do not need to memorize those names to get the intuition: Strong means “I can apply a transformation to one part of a pair and keep the other part untouched.” Choice means “I can apply a transformation to one branch of an either, and leave the other branch alone.” Wander means “I can apply a transformation to every focus inside some traversable shape and rebuild it.”

Once you have this encoding, an optic becomes a single function that works for any profunctor with the right structure. That is what gives profunctor optics their power: the optic is independent of how you later interpret it.

Why Use Them?
#

Profunctor optics can look abstract, but the payoff is significant!

Optic kindEncodingCapability on $p$Intuition
Lens$p\ A\ B \to p\ S\ T$Strongact on one part of a product
Prism$p\ A\ B \to p\ S\ T$Choiceact on one branch of a sum
Traversal$p\ A\ B \to p\ S\ T$Wanderact on all focuses in a shape

From that one encoding, you get:

  1. Uniform representation: Lenses, prisms, and traversals can all be represented in one shape $p\ A\ B \to p\ S\ T$, only different profunctor capabilities are required.
  2. Type-directed composition: Optics become function-transforming profunctors, then composing optics is just ordinary function composition. Their enclosed types ensure only compatible optics can be composed.
  3. Multiple interpretations: Different $p$ give different behavior (get/set update vs query/fold vs effectful traversal) without rewriting the same optic.

Conclusion
#

Without optics, a lot of code ends up reimplementing the same shape of “dig in, modify, rebuild” for many different structures. Optics let you name these focuses once and reuse them everywhere. In a follow-up post, we’ll translate this idea into a concrete implementation and see what an idiomatic design looks like. Stay tuned!


If you liked this post and want to show your support, consider sponsoring me! I use donations to buy coffee ☕️ and write more articles that you'll love. ❤️   Donate Now 🙏  
Understanding - This article is part of a series.
Part 4: This Article