Composing Lenses
To cap things off, let's talk about how lenses compose. Spoiler alert: it looks backwards, but it's not. (8 min. read)
We'll cover the following
Let’s go back a few lessons and review our code from Use With Arrays. We wished to access an object’s third friend using lensIndex
.
import { lensIndex, view } from 'ramda';const person = {firstName: 'Bobo',lastName: 'Flakes',friends: [{firstName: 'Clark',lastName: 'Kent'}, {firstName: 'Bruce',lastName: 'Wayne'}, {firstName: 'Barry',lastName: 'Allen'}]};const getThirdFriend = lensIndex(2);const result = view(getThirdFriend, person.friends);console.log({ result });
A Bit Too Specific
It works fine, but look at how view
's being used.
view(getThirdFriend, person.friends);// person.friends?
Lenses help decouple your logic and data, so it’s counterintuitive to specify person.friends
. The whole point’s to just pass in person
and let the lens do the work for us!
But lensIndex
only works on arrays, so how can it focus on friends
before the index? Say it with me: function composition! We’ll just compose lensIndex
with lensProp
and get our result.
import { pipe, lensIndex, lensProp, view } from 'ramda';const person = {firstName: 'Bobo',lastName: 'Flakes',friends: [{firstName: 'Clark',lastName: 'Kent'}, {firstName: 'Bruce',lastName: 'Wayne'}, {firstName: 'Barry',lastName: 'Allen'}]};const getThirdFriend = pipe(lensProp('friends'),lensIndex(2));const result = view(getThirdFriend, person);console.log({ result });
This Is Wrong
There ya go, nice and…wait. This returns undefined
… Why?! The composition looks correct.
const getThirdFriend = pipe(lensProp('friends'),lensIndex(2));
- Get
friends
- Get third one (index 2)
Believe it or not, we composed them backwards. Check this out.
import { pipe, lensIndex, lensProp, view } from 'ramda';const person = {firstName: 'Bobo',lastName: 'Flakes',friends: [{firstName: 'Clark',lastName: 'Kent'}, {firstName: 'Bruce',lastName: 'Wayne'}, {firstName: 'Barry',lastName: 'Allen'}]};// Flip the compositionconst getThirdFriend = pipe(lensIndex(2),lensProp('friends'),);const result = view(getThirdFriend, person);console.log({ result });
This Is Correct
const getThirdFriend = pipe(lensIndex(2),lensProp('friends'),);
Now it works. Why?
Back to the Functor
The last lesson introduced the relationship between functors and lenses.
After receiving a getter/setter, the lens requires a toFunctorFn
–a function that turns a value into a functor.
This is because Ramda’s map
relinquishes control to any functor carrying that special fantasy-land/map
property, allowing view
, set
, and over
to do their jobs.
Look again at our composition.
const getThirdFriend = pipe(lensIndex(2),lensProp('friends'),);
So lensIndex(2)
returns a function expecting its toFunctorFn
, as does lensProp('friends')
.
Following the pipe
sequence leads us to an interesting conclusion: lensIndex(2)
is the toFunctorFn
to lensProp('friends')
! This is how they’re composing. We can prove it with some logs.
import { tap, pipe, lensIndex, lensProp, view } from 'ramda';const person = {firstName: 'Bobo',lastName: 'Flakes',friends: [{firstName: 'Clark',lastName: 'Kent'}, {firstName: 'Bruce',lastName: 'Wayne'}, {firstName: 'Barry',lastName: 'Allen'}]};// Flip the compositionconst getThirdFriend = pipe(tap((fn) => {console.log('lensIndex will be called with this\n');console.log(fn.toString());console.log('\n');}),lensIndex(2),tap((fn) => {console.log('lensProp will be called with this\n');console.log(fn.toString());console.log('\n');}),lensProp('friends'),tap((fn) => {console.log('The composition is this:\n');console.log(fn.toString());console.log('\n');}),);const result = view(getThirdFriend, person);console.log({ result });
Unfold
Carefully read these logs. Passing view
to the composition of lensIndex(2)
and lensProp('friends')
created the following sequence of events:
-
view
gave atoFunctorFn
tolensIndex(2)
. -
Now
lensIndex(2)
awaits its data. -
lensIndex(2)
becomes atoFunctorFn
forlensProp('friends')
-
lensProp('friends')
now awaits its data.
// the composed lensfunction (target) {return map(function (focus) {return setter(focus, target);}, toFunctorFn(getter(target)));}
-
view
feeds it ourperson
data. -
map
fires like a rocket, rapidly unfolding the functor in the correct order, according to the getters and setters. -
The getter for
lensProp('friends')
is called first, soperson.friends
is retrieved -
That data is then passed to
lensIndex(2)
, who grabs the third element,person.friends[2]
. -
You get your data back.
compose() Instead of pipe()
This takes some time to get used to. If you, like me, prefer pipe
because it reads left-to-right, compose
lets you write lenses left-to-right as well.
const getThirdFriend = compose(lensProp('friends'),lensIndex(2));
import { compose, lensIndex, lensProp, view } from 'ramda';const person = {firstName: 'Bobo',lastName: 'Flakes',friends: [{firstName: 'Clark',lastName: 'Kent'}, {firstName: 'Bruce',lastName: 'Wayne'}, {firstName: 'Barry',lastName: 'Allen'}]};const getThirdFriend = compose(lensProp('friends'),lensIndex(2));const result = view(getThirdFriend, person);console.log({ result });
Summary
-
Compose lenses to handle objects and arrays at the same time (
lensProp
+lensIndex
). -
Lenses don’t compose backwards, one combines with the next by acting as its
toFunctorFn
. -
Once built up and given the data,
map
takes that “giant” lens and calls itstoFunctorFn
, which is a composition of every lens that came before it. -
This unfolding lets
map
drill all the way down through your data and return the property you’re after. -
Use
compose
if you want to read lenses from left-to-right.