📹 Hate reading articles? Check out the complementary video, which covers the same content.
The premise is simple. I think people go too crazy with optics. If you want to use optics, here is 90% of what you need:
- Lenses and traversals;
- How to compose them;
- How to create them;
- Use these four operators:
view
,set
,over
, andtoListOf
.
(Or their alternatives in other language, more on that later).
And that’s it! You get a colossal toolbox and a productivity boost in less than ten functions. The core knowledge is fundamental – it goes beyond libraries and languages.
💡 If you’ve read any optics tutorials before, you might wonder: What about prisms and other optics? Or what about different encodings? They seem to be so important.
No, they are not. Don’t waste your time when getting started. You can pick these up later (if you want to).
We have to start with boring parts. The first step is to pick and install the library. I’ll go with PureScript and profunctor-lenses
because it’s less noisy and nicer for demos. But the basic ideas apply to other languages, like Haskell and Scala.
spago install profunctor-lenses
Then we have to prepare the data. Imagine that we’re working on some bar order service – we can start with a simple highball type.
🍸 Highball is a simple long drink with one liquor and one mixer.
type Order =
{ drink :: Highball
}
type Highball =
{ liquor :: Liquor
, mixer :: Mixer
, ounces :: Int
}
data Liquor = Scotch | Gin
data Mixer = Soda | Tonic
And a couple of instances to show/see what we’re doing:
derive instance Generic Liquor _
instance Show Liquor where
show = genericShow
derive instance Generic Mixer _
instance Show Mixer where
show = genericShow
Lens
- A lens deconstructs product types, such as records and tuples.
- A lens must always focus on the value.
In our case, we can create a lens that focuses on the drink
field (or part) of the Order
record and lenses that focus on liquor
, mixer
, and ounces
of Highball
. Let’s write a couple.
Disclaimer for Java boilerplate flashbacks. This is the only boilerplate we have to write.
drinkLens :: Lens' Order Highball
drinkLens = prop (Proxy :: _ "drink")
liquorLens :: Lens' Highball Liquor
liquorLens = prop (Proxy :: _ "liquor")
drinkLens
is a lens from Order
(whole) to Highball
(part); we create it by using a function prop
and passing it the name of the field drink
. We use Proxy
because it’s type-level information – if we pass a wrong non-existing field (for example, drank
), it won’t compile.
Lens composition
We can already use these, but they're boring and not exciting.
Optics are most useful for nested data structures. One of the most remarkable things about optics is composition. We can compose two lenses to get a “larger” lens.
Let’s compose drinkLens
and liquorLens
to get a lens from Order
to Liquor
.
orderedLiquor :: Lens' Order Liquor
orderedLiquor = drinkLens <<< liquorLens
💡 (<<<)
is the composition operator in PureScript.
Note that we don’t have to declare intermediate optics (when needed):
orderedOunces :: Lens' Order Int
orderedOunces = drinkLens <<< prop (Proxy :: _ "ounces")
And finally, we can see lenses in use.
Using lenses
We can use lenses to get, set, or modify a value within a structure when we know it exists.
Imagine we have an order:
let myOrder = { drink: { liquor: Scotch, mixer: Soda, ounces: 10 } }
We can use the lens (from Order
to Liquor
) to get the value using the view
function:
view orderedLiquor myOrder
-- Scotch
We can use the lens to set the value using the set
function; for example, to switch to Gin
:
set orderedLiquor Gin myOrder
-- { drink: { liquor: Gin, mixer: Soda, ounces: 10 } }
We can use the lens to modify the value using the over
function; for example, to bump the drink size:
over orderedOunces (_ + 1) myOrder
-- { drink: { liquor: Scotch, mixer: Soda, ounces: 11 } }
Which might remind you of using a map
function.
💡 Note that lenses aren’t limited to the same type, we can use type-changing lenses and operations, but we keep it simple for now.
Quick recap
- A lens focuses on one part of the structure, such as a field of a record.
- We can compose two lenses to get another lens (using
(<<<)
). - We can use the
props
function to create a lens inpurescript-profunctor-lenses
. - We can use
view
to get,set
to set, andover
to modify values with a lens.
Traversal
Let’s modify the Order
type to allow multiple drinks instead of just one.
type Order =
{ drinks :: Array Highball
}
The code doesn't compile anymore – we have to fix our lenses.
The drinkLens:: Lens' Order Highball
is invalid because the field's name and type have changed. We have to patch it:
-- drinkLens :: Lens' Order Highball
-- drinkLens = prop (Proxy :: _ "drink")
drinksLens :: Lens' Order (Array Highball)
drinksLens = prop (Proxy :: _ "drinks")
The compiler is still not happy, pointing fingers at orderedLiquorLens
:
orderedLiquors :: Lens' Order Liquor
orderedLiquors = drinkLens <<< liquorLens
-- ^^^^^^^^^^
-- Compilation error:
-- Could not match type Record with type Array
It says it could not match the type Record
with the type Array
:
-
drinksLens
goes fromOrder
toArray Highball
; -
liquorLens
goes fromHighball
toLiquor
.
So, now we have a “gap” or a mismatch between Array Highball
and Highball
. We can’t use a lens here because a lens must always focus on the value. But arrays can have one, multiple, or even zero elements (values).
Here is where the traversals come in. Traversal focuses on multiple values in a structure (or collection).
Making and composing traversals
To fix the code, we must add a traversal to the composition and change the type from Lens'
to Traversal'
. Composing a lens with a traversal or two traversals makes a traversal.
orderedLiquor :: Traversal' Order Liquor
orderedLiquor = drinksLens <<< traversed <<< liquorLens
The traversed
function creates a traversal; we can extract it to make this explicit and clear:
orderedLiquor :: Traversal' Order Liquor
orderedLiquor = drinksLens <<< allDrinksTraversal <<< liquorLens
allDrinksTraversal :: Traversal' (Array Highball) Highball
allDrinksTraversal = traversed
Which is more verbose but can be nice for learning and figuring out the types.
And same with the other optic:
orderedOunces :: Traversal' Order Int
orderedOunces = drinksLens <<< traversed <<< prop (Proxy :: _ "ounces")
Using traversals
We can use traversals to modify all values (like map
or traverse
) or get all the values.
💡 We can also combine all the values into a single value (like fold
). But it’s not crucial for now.
Let’s modify the order by adding another drink:
myOrder =
{ drinks:
[ { liquor: Scotch, mixer: Soda, ounces: 10 }
, { liquor: Gin, mixer: Tonic, ounces: 10 }
]
}
We can use a traversal (from Order
to Liquor
) to modify all values using the over
function as we did with a lens; for example, to bump all the drink sizes in the order. We don’t even have to change the code:
over orderedOunces (_ + 1) myOrder
-- { drinks: [{ liquor: Scotch, mixer: Soda, ounces: 11 },{ liquor: Gin, mixer: Tonic, ounces: 11 }] }
We can also use a traversal to get all the values using toListOf
(or toArrayOf
):
toArrayOf orderedOunces myOrder
-- [10,10]
toListOf orderedLiquor myOrder
-- (Scotch : Gin : Nil)
Quick recap
- A traversal focuses on 0, 1, or many values of the structure (or collection).
- We can compose lenses and traversals to get traversals.
- We can use the
traversed
function to create a traversal. - We can use
over
to modify values with a traversal andtoList
to get the list of values.
đź’ˇ Cheat sheets and references:
Top comments (0)