In this post I’d like to try to discuss what functional optics are, without going too much into why they are so cool, and you should use them, or how they are implemented1 and should be used with a specific language and library.
I personally think that functional optics should be a really easy concept to grasp, but currently learning them is harder than it should be mostly due to library implementation details, quite obscure documentation and an exotic usage of weird symbols.
Since a picture is worth a thousand words, I will introduce and use a graphical notation to illustrate the concepts we will discuss.
Types and values
Let’s start introducing our graphical notation from its basic building blocks.
We can represent a type with a simple coloured rectangle
A value for a given type will be represented as a horizontal line spanning the width of the rectangle
Sums and products
When considering algebraic data types, we have two ways of combining types, using products and sums.
The product of two types A and B is a new type, which we will denote by A*B, whose values are composed of a value of type A and a value of type B. An example of a product type is a tuple like (Int, String) where each value is pair composed of an integer and a string.
Graphically we can represent a product type as two side by side rectangles
When it comes to values, we need to upgrade a little bit our graphical interpretation. Since a value in a product type is composed of values of its components, we will just represent it as piecewise horizontal line, composed by horizontal lines (possible at different heights) spanning its horizontal sub-rectangles.
On the other hand, the sum of two types is represented by two rectangles one on top of the other
A value of a sum type is a horizontal line spanning the width of the whole rectangle. If it is a horizontal line in the top rectangle, it means that we are selecting the first type, and we’re using one of its values.
If it is a horizontal line in the bottom rectangle, it means that we are selecting the second type and one of its values.
More generally, for any algebraic data type, we can represent it as a sum of products by stacking a series of rectangles one on top of the other, each one potentially divided horizontally in multiple sub-rectangles.
In general, we can continue to split any sub-rectangle horizontally or vertically (if you prefer a top-down point of view) or you can place two rectangles side by side or top to bottom.
A value of such a type is a piecewise horizontal line which can not cross a horizontal division.
Optics
Now that we have this graphical representation to represent data types, we can use it to discuss various kinds of optics.
In general, we can think of an optic as a way to select, given our graphical representation of a type, one (or more) rectangle inside a given rectangle representing a type. For example in the following picture we are selecting the rectangle with the red boundary inside the main rectangle representing a complex type.
If call the main type A and the selected type B, we will denote the optic selecting B inside A with Optic A B.
Before going into inspecting the various kinds of optics, let’s try to see if can can already derive some properties of optics just by looking at their graphical representation.
Compositionality
One thing that we can notice is that optics compose really well. Suppose we have a type A represented by the following diagram
We can first select a sub-rectangle identifying a type B with an Optic A B.
Starting now with the type B we can use an Optic B C to select a type C inside B.
Using now the Optic A B and the Optic B C we just chose, we can compose them to obtain an Optic A C which directly selects C inside A.
This optic composition operation is actually associative and has an identity element, turning optics into a Category.
Let’s now start to have a look at some specific families of optics.
Iso
The simplest optic we can define for any type A is the one that we can obtain by selecting the whole rectangle.
With such a selection we can see that for any value of the outer type A, we actually have a value of the type identified by the red rectangle, which we will call B.
This means that, given an Iso A B, we can actually define a function view :: A -> B that for any value of A gives us a value of B.
But in this special case also the converse holds! For any value of B, since B is actually A itself, we have in fact a value of A. This gives rise to a function review :: B -> A.
In fact review . view = id_A and view . review = id_B giving rise to a proper isomorphism.
Lens
In our graphical representation, a Lens is a vertical slice of the main rectangle.
Any vertical slice cuts out a piece out of any horizontal line. In other terms, given a value of the type A represented by the main rectangle, we have a way to obtain a value of type B represented by our vertical slice. This means that also in this case we are able to define a function view :: A -> B which allows us to focus from the main type to one of its component.
On the other hand, it’s not possible with Lenses as it was with Isos to build back a value of type A from a value of type B, since a value of type B is only a part of value of type A. What is actually possible, though, is to update only the part included in the red rectangle of a value of type A. In other terms, given a Lens A B, we can define a function set :: B -> A -> A which takes a value of type B and a value of type A and updates the section of the latter identified by the Lens.
Having a look at the graphical representations of the view and set functions, we can convince ourselves that the following properties hold:
- If we set a value and then we view it, we must get back what we put in: view (set b a) == b.
- If we set what we get out of a view, nothing changes: set (view a) a == a.
- Setting a value twice is the same thing as setting it once: set b (set b a) == set b a.
Moreover, we can notice that composing two lenses with the operation described in the Compositionality section gives us back another lens. A vertical slice of a vertical slice is in fact still a vertical slice of the original rectangle. In other terms this means that Lenses form a subcategory of the bigger category of Optics.
Composing adequately set and view we can also define a function over :: (B -> B) -> A -> A as over f a = set (f $ view a) a. This means that if we have a function f :: B -> B which can transform values of type B, we can use our lens to extract a B from an A via view, use f to transform the result, and eventually use set to update the B part inside the original A.
Prism
If vertical slices are Lenses, it is only natural to wonder what are horizontal slices. They correspond to Prisms, and they are the dual concept of Lenses. Where a Lens represents a component in a product type, a Prism represents a component in a sum type.
Looking at values, we can notice that a value in the main type could either be a value of the inner type or it could be completely outside of it. This implies that, given a Prism A B, we can define a function preview :: A -> Maybe B which, given a value a :: A returns a Just b if a was inside the sub-rectangle identified by B and Nothing otherwise.
On the other hard, since a Prism constitutes a horizontal slice of the main rectangle, if we have a value of the sub-rectangle, we can always interpret it a value of the main rectangle. In other words, this means that for a Prism A B we can always define a function review :: B -> A constructing a value of type A from a value of type B.
Again, having a look at the graphical representation we can convince ourselves that the following properties hold for Prisms:
- If we preview through a Prism what we just built using the same Prism, we will get a value back: preview (review b) == Just b.
- If when we preview we get a Just, then reviewing the result through the same Prism will get us to the initial value: preview s == Just a => review a == s
For a Prism A B it is also possible to define a function set :: B -> A -> A as set = flip $ const review. This means that, being able to construct an A from a B, we are able to substitute a B inside an A just by discarding the initial A and building a new one from B. Graphically, we can interpret this as using the B value in the inner rectangle to build an A value, forgetting about the initial A value.
At this point we can also define another function over :: (B -> B) -> A -> A which allows us to update the B part inside an A. We can define it as over f a = maybe a review (f <$> preview a). In words, we use preview to get a Maybe B and we map f over it to get another Maybe B; if we have a value Just b, then we can use it to construct an A using review; on the other hand, if we ended up with a Nothing, we just keep the initial A. Graphically, we can interpret this as follows: if the A value is inside the B sub-rectangle, we apply f and then we use the result to build a new A value; if the value is not in B, we just leave it alone.
Looking at the graphical interpretation, it’s easy to convince ourselves that the composition of two Prisms is still a Prism, given that a horizontal slice of a horizontal slice is still a horizontal slice of the main rectangle. In other terms, also Prisms form a subcategory of the category of Optics.
Affine traversals
Now that we discussed Lenses and Prisms, one natural question which might arise is what happens when we try to compose a Lens and a Prism.
In the picture above we see a Lens (the blue rectangle) composed with a Prism (the red rectangle). What we get out of the composition is the lower right rectangle, which is neither a Lens, nor a Prism, with respect to the main rectangle. It’s just a single inner rectangle.
On the other hand, if you think about it, every inner rectangle of the main rectangle could be obtained by composing Lenses and Prisms.
An Optic identifying an inner rectangle is called an AffineTraversal.
Combining the intuitions we had for Lenses and Prisms, it’s actually possible to define functions set :: B -> A -> A and over :: (B -> B) -> A -> A also for AffineTraversals.
Moreover, the graphical representation suggests us that also AffineTraversals for a subcategory of Optics, since a sub-rectangle of a sub-rectangle is actually a sub-rectangle of the initial one.
Why stop at one?
All the Optics that we discussed so far focus on a single sub-rectangle. But, if we want, we can consider also Optics which focus on multiple sub-rectangles at the same time.
We will denote by Traversal A B the Optics which focus on multiple sub-rectangles of type B inside a main rectangle of type A.
For Traversals we can still define set :: B -> A -> A which replaces all the selected sub-rectangles of type B, inside the main rectangle of type A, with the same vale b of type B, to produce a new A.
Similarly, we can define over :: (B -> B) -> A -> A which applies a function to all the selected sub-rectangles of type B, inside the main rectangle of type A, to produce a new A.
Another relevant function which makes sense to consider for Traversals is toListOf :: A -> [B], which extracts all the values of the selected sub-rectangles of type B from the main rectangle of type A.
As usual, we can notice that Traversal A B form a subcategory of Optic A B, since a selection of sub-rectangles inside a selection 0f sub-rectangles is still a selection of sub-rectangles of the main rectangle.
Conclusion
The graphical representation we just introduced in this post provides us with a tool to navigate various kinds of Optics and their operations. I hope it can provide a concrete way to understand the basic ideas behind Lenses, Prisms and other Optics and make it easier to use them.
Such a representation could also help to explore and shed some light on the mysterious world of Optics. One could try to search for other sub-categories in a graphical fashion and then ask what do they correspond to in other Optic representation. For example, what is the sub-category of Optics made by multiple horizontal slices? Or the one made by multiple vertical slices?
I need also to mention that such a representation is not able, as far as I can see, to fully represent the whole universe of Optics. For example, it’s hard to distinguish a Traversal from a Fold, or describe what Grates are.
All in all, I’m confident that describing and explaining optics in this graphical fashion could help people understand their beauty and usefulness! Thanks for reading up to here!