Lenses provide a means to decouple an object's shape from the logic operating on that object. It accomplishes this using the getter/setter pattern to 'focus in' on a sub-part of the object, which then isolates that sub-part for reads and writes without mutating the object.
This can bring about multiple benefits. Let's start with the shape decoupling nature of lenses.
Decoupling an object's shape allows for future reshaping of your data while minimizing the effects of the rest of the code in your application. Take, for example, an object representing a person.
const person = {
firstName: 'John',
lastName: 'Doe'
}
Now imagine the shape of that object changes such that the firstName
and lastName
properties are replaced with a single property called name
which is itself an object containing properties first
and last
:
const person = {
name: {
first: 'John',
last: 'Doe'
}
}
Any code working with that object would now need to be updated to reflect the object's change in shape. This is prevented in OOP through use of classes which hide the data's internal structure and provides access through a getter/setter API. If the shape of a class's internal data changes, all that needs updated is that class's API. Lenses provide the same benefit for plain old objects.
Another benefit to lenses is the ability to write to an object without mutating the object in the process. The non-mutation of data is, of course, one of the staples of FP (functional programming). The problem is, the larger and more complex the data you're working with is, the more difficult it becomes to change deeply nested data without mutations. As we'll see later on, lenses simplify the process with just a couple lines of code no matter how complex your data is.
And finally, lenses are curryable and composable, which makes them fit in well with the FP paradigm. We'll use both of these in later examples.
With the help of Ramda, let's create a lens for working with the person's firstName
.
const person = {
firstName: 'John',
lastName: 'Doe'
}
We'll start with Ramda's most generic lens creation function called simply lens(). As mentioned previously, lenses use the getter/setter pattern for reading and writing data to our object. Let's create those first.
const getFirstName = data => data.firstName // getter
const setFirstName = (value, data) => ({ // setter
...data, firstName: value
})
And then the lens itself:
const firstNameLens = lens(getFirstName, setFirstName)
The lens()
function takes two arguments, the getter and setter we defined previously. The lens is then ready to be applied to an object, in this example, the person object. But before we do so I want to point out a few things.
- The lens, itself, isn't given a reference to any data. This makes the lens reusable and able to be applied to any data, as long as that data conforms to the shape required of its getter and setter arguments. In other words, this lens is only useful when applied to data that has a
firstName
property, which could be a person, an employee, or even a pet. - Since the lens isn't tied to any specific piece of data, the getter and setter functions need to be given the data they'll be operating on. The lens will take the object it's applied to and automatically pass it to the supplied getters and setters for you.
- Since FP doesn't allow the mutation of data, the setter must return an updated copy of the data that the lens is applied to. In this example, our lens will be applied to a person object so the lens's setter function will return a copy of the person object.
Let's look at how we can use the lens to read from an object using Ramda's view() function:
view(firstNameLens, person) // => "John"
The view()
function takes two arguments; a lens, and an object to apply that lens to. It then executes the len's getter function to return the value of the property the lens is focused on; in this case, firstName
.
It's also worth noting that view()
is curryable, in that we can configure view()
with just the lens and supply the object later. This becomes particularly handy if you want to compose view()
with other functions using Ramda's compose(), pipe(), or various other composition functions.
const sayHello = name => `Hello ${name}`
const greetPerson = pipe(
view(firstNameLens),
sayHello
);
greetPerson(person) // => "Hello John"
Now let's see how we can write to an object with our lens using Ramda's set() function:
set(firstNameLens, 'Jane', person)
// => {"firstName": "Jane", "lastName": "Doe"}
The set()
function also takes a lens and an object to apply that lens to, as well as a value to update the focused property. And as mentioned earlier, we get back a copy of the object with the focused property changed. And, just like view()
, set()
is curryable allowing you first configure it with a lens and value and provide it with data later.
There's a third lens application function called over(), which acts just like set()
except, instead of providing an updated value, you provide a function for updating the value. The provided function will be passed the result of the lens's getter. Let's say we want to uppercase the person's firstName
:
over(firstNameLens, toUpper, person)
// => {"firstName": "JOHN", "lastName": "Doe"}
We're also making use of Ramda's toUpper() function. It's the equivalent of:
const toUpper = value => value.toUpperCase()
I want to go back to our original getter and setter functions and look at more concise ways they can be written.
const getFirstName = data => data.firstName // getter
const setFirstName = (value, data) => ({ // setter
...data, firstName: value
})
If we're using Ramda for lens creation, it only makes sense to take advantage of Ramda functions for other parts of our code. In particular we'll use Ramda's prop() function to replace our getter and the assoc() function to replace our setter.
The prop()
function takes a property name and an object, and returns the value of that property name on that object. It works very similarly to our getter function.
prop('firstName', person) // => "John"
Again, as with most all Ramda functions, prop()
is curryable, allowing us to configure it with a property name and provide the data later:
const firstNameProp = prop('firstName')
firstNameProp(person) // => "John"
When using it with a lens, we can configure it with a property name and let the lens pass its data later.
lens(prop('firstName'), ...)
This is also an example of point-free style or tacit programming in that we don't define one or more of the arguments (in this case, person) in our logic. It can be hard to see how this works if you're not used to this style commonly found in FP, but it can make more sense when broken down...
When passing a single argument to a multiary (multi-arg) curried function, it returns a new function accepting the rest of the arguments. It's not until all the arguments are supplied that it executes its function body and returns the results. So when configuring prop()
with just the property name, we'll receive a new function that takes the data argument. That matches perfectly with what a lens getter is: a function that takes a data argument.
The assoc()
function works the same way, but is designed for writing rather than reading. In addition, it'll return a copy of the object it's writing to, which is the same functionality required by a lens setter.
assoc('firstName', 'Jane', person)
// => {"firstName": "Jane", "lastName": "Doe"}
When used with a lens, we can configure assoc()
with just the property name, and let the set()
function curry the value and data through.
const firstNameLens = lens(prop('firstName'), assoc('firstName'))
view(firstNameLens, person) // => "John"
set(firstNameLens, 'Jane', person)
// => {"firstName": "Jane", "lastName": "Doe"}
Those are basics of lenses but there are other, more specialized, lens creation functions in Ramda. Specifically, lensProp(), lensIndex(), and lensPath(). These are the functions you'll probably use most often when creating lenses. The generic lens()
would be used only when needing to do very customized lens creation. Let's go over each of these specialized lens creation functions.
The lensProp()
function takes a single argument; a property name.
const lastNameLens = lensProp('lastName')
And that's it! The property name is all it needs to generate the appropriate getter and setter:
view(lastNameLens, person) // => "Doe"
set(lastNameLens, 'Smith', person)
// => {"firstName": "John", "lastName": "Smith"}
The lensIndex()
function works similarly to lensProp()
except it's designed for focusing in on an array index, and therefore, you pass it an index rather than a property name. Let's add an array of data to our person to test it out.
const person = {
firstName: 'John',
lastName: 'Doe',
phones: [
{type: 'home', number: '5556667777'},
{type: 'work', number: '5554443333'}
]
}
Then when applying the lens...
const firstPhoneLens = lensIndex(0)
view(firstPhoneLens, person.phones)
// => {"number": "5556667777", "type": "home"}
set(
firstPhoneLens,
{type: 'mobile', number: '5557773333'},
person.phones
)
// => [
// {"number": "5557773333", "type": "mobile"},
// {"number": "5554443333", "type": "work"}
//]
Notice how when applying the lens we have to pass in person.phones
. While this works, it's less than ideal because now we're relying on knowledge of the object's shape in our general application code, rather than hiding it in our lens. In addition, when applying the lens with the set()
function, we get back the array of phones, not the person. This emphasizes that whatever object you give the lens application, the same is what you get back. The likely next step would be to merge the new array of phones back into the person object. This would, of course, need to be done in a non-mutating way... something that Ramda could handle easily. However, it'd be better to not even have to take that extra step. That leads us to the third specialized lens, lensPath()
which is designed for focusing in on nested data.
const homePhoneNumberLens = lensPath(['phones', 0, 'number'])
view(homePhoneNumberLens, person) // => "5556667777"
set(homePhoneNumberLens, '5558882222', person)
// => {
// "firstName": "John", "lastName": "Doe"
// "phones": [
// {"number": "5558882222", "type": "home"},
// {"number": "5554443333", "type": "work"}
// ]
//}
As you can see, lensPath()
takes an array with path segments leading to the nested data that we want focused. Each path segment can be a property name or an index. Since we're giving it the root person object, we get back a full copy of the person object with just the home phone number changed. In my opinion, this is where the lens feature really starts to shine. Imagine if we wanted to duplicate the result of the set()
function above, but with regular Javascript. Even with the latest features, such as spreading and destructuring, we might end up with something like the following:
const [homePhone, ...otherPhones] = person.phones
const updatedPerson = {
...person,
phones: [
{...homePhone, number: '5558882222'},
...otherPhones
]
}
That's quite a bit of work compared to the two line example using lenses!
One of the more powerful features of lenses is their ability to be composed together with other lenses. This allows you to build up new and more complex lenses from existing ones:
const phonesLens = lensProp('phones')
const workPhoneLens = lensIndex(1)
const phoneNumberLens = lensProp('number')
const workPhoneNumberLens = compose(
phonesLens,
workPhoneLens,
phoneNumberLens
)
view(workPhoneNumberLens, person) // => "5554443333"
The result is not too different from using a single lensPath()
. In fact, if I had no need for the individual phonesLens
and workPhoneLens
in other contexts, I'd probably just use a lensPath()
instead. However, the nice thing about this approach is that no one lens has the full knowledge of the entire shape of a person. Instead, each lens just keeps track of their own pieces of shape, relieving that responsibility from the next lens in the composition. If we were to, for example, change the property name phones
to phoneList
, we'd only need to update the lens responsible for that piece of shape (phoneLens
) rather than updating multiple lenses that happen to overlap that path.
And that's a rundown of the features and benefits of functional lenses in Javascript with Ramda.
Top comments (0)