Lenses are a neat, if theoretically-convoluted, set of operations that allow us to easily manipulate information in nested contexts. Records are the obvious use case for these constructs, but they shine across algebraic data types as well.
What follows is not a lens tutorial: just my notes on a specific case involving ADTs. What I've written here should be obvious to experienced Haskellers, but it wasn't immediately clear to me. As with all my other Haskell pieces, I'm actually using Purescript here. There are small syntax differences and a lot more boilerplate.
Consider an algebraic data type F
with two instances: A
and B
. A
contains one value of type X
, and B
contains types of X
and Y
respectively. The actual nature of these constituent types is irrelevant, but I've made them strings here.
import Prelude (($), (<<<))
import Data.Lens (Lens', Prism', lens, preview, prism, set, view)
import Data.Maybe (Maybe(..))
Type X = String
Type Y = String
Data F = A X | B X Y
Since both instances of F
contain an X
value, a lens can be declared over it, allowing us to access and modify this value without explicit deconstruction of the data type in every procedure. A lens is a pair of two functions: one to access a context's value, and one to modify it. A lens over X
in F
requires defining these set
/get
functions for both A
and B
.
x ∷ Lens' F X
x = lens get set where
get (A x ) = x
get (B x _) = x
set (A _ ) x = (A x)
set (B _ y) x = (B x y)
The view
function allows one to retrieve the value of X
from either instance of F
, while the set
function modifies it.
> view x (A "hey")
"hey"
> set x "yo" (B "hey" "there")
(B "yo" "there")
This is all well and good for X
, which exists in both instances, but what about Y
, which can only be found in B
? A prism is needed. This construct offers a view on a specific value much like a lens does, but focuses on a single instance of an ADT. It is used alongside the preview
function, which will return Nothing
if the instance doesn't match.
Long story short: Y
must first be viewed through a prism on B
, being returned as Just Y
if F
is shaped like B
, and Nothing
if F
is an A
.
The problem I ran into, however, is that the prism constructor expects only one argument. I needed to declare the body of B
as a record type B'
first.
type B' = {x ∷ X, y ∷ Y}
I was then able to write a prism. Much like a lens expects set
/get
, a prism needs review
/preview
defined, so that it can create a B
from B'
, or potentially return a B'
from F
.
b ∷ Prism' F B'
b = prism' review preview where
review {x, y} = B x y
preview (B x y) = Just {x, y}
preview _ = Nothing
As seen above, the prism over b will yield its entire body as a record if an F
is B
, and Nothing
otherwise. Returning a record meant that I also had to declare a lens on the field y
.
y ∷ Lens' B' Y
y = lens _.y $ _ {y = _}
The true strength of lenses and prisms is revealed once you start composing them. It is now possible to access Y
without regard to the shape of an F
; if it's B
, we'll get something, but it won't blow up otherwise.
> preview (b <<< y) (B "hey" "there")
(Just "there")
> preview (b <<< y) (A "hey")
Nothing
Imagine an ADT in a nested record structure behind some Either
s or whatever, and you can see how a chain of lenses and prisms can provide unparalleled focus upon what would otherwise be a mess of conditional logic.
If you want to avoid using a bridging structure like B'
, it makes more sense to give every instance of an algebraic data type exactly one value: a record.