An Introduction Into Lenses In JavaScript
Functional Getter/Setter
Originally published on Nov 17, 2016 and also available here.
Introduction
If you know the ins and outs of lenses, including when and how to use them, then this walkthrough might not be for you. For everyone else, who might or might not have heard about lenses, this write-up is intended as an introductory into the concept.
Basics
What are lenses? In short, they are functional getter/setter. A short hand functionality for accessing as well as updating data objects. Now what does this look like in the real world and why should we use it? Let’s start off with a simplified example.
const user = {id: 1, name: 'userA'}
To access the user id we would write something like the following.
user.id // 1
Updating could be accomplished like this f.e.
user.id = 2
Now, mutating the user object might not be the best idea, as it might lead to some unexpected behaviours later on. We might assume some value to be defined, but have no guarantees.
So let’s bring in a couple of utility functions that could improve the previous approach.
Getting Started
We’ll add a couple of Ramda functions (you can also use lodash/fp).
assoc: for overriding a specified property and getting a new object in return.
prop: for accessing an object property.
So a cleaner variant of accessing and updating the user id can be achieved by applying these functions.
prop('id', user) // 1
assoc('id', 2, user) // {id: 2, name: 'userA'}
Updating the user id in the second example doesn’t mutate our user object. This is what we want to ensure in the first place.
Due to the fact that we have prop and assoc for updating and retrieving an object’s specified properties, we can start thinking about what lenses mean in this context. We know that lenses are functional getter/setter and our previous examples already enabled us to get and set properties, so let’s write some pseudo code to combine these things together.
const idLens = lens(prop('id'), assoc('id'))
view(idLens, user) // 1
set(idLens, 2, user) // // {id: 2, name: 'userA'}
We introduced a couple of new functions here, so let’s go through them one by one and see how this all fits together.
The first function lens expects two arguments, the first being a getter and the second being a setter. This doesn’t really need too much explanation, contrary to the next two lines. What do view and set do? view expects a lens and an object to apply the lens on. set expects three arguments, the lens the new value and the user. It then updates the defined field by applying the lens with the new value, just like the name implies.
What we have up until now is pseudo code, so to get a better feel, let’s create a naive implementation before we fallback to a tested and ready-to-use solution.
const lens = (getter, setter) => {
return ({
get: obj => getter(obj),
set: (val, obj) => setter(val, obj),
})
}
Admitted, it’s not the nicest of all solutions, but it should work. Now that we have our own lens function in place, let’s figure out how view and set might work.
const view = (lens, obj) => {
return lens.get(obj)
}
const set = (lens, val, obj) => {
return lens.set(val, obj)
}
Actually, we could just call get on the lens object when using view and the lens set method when applying the standalone set function. Rerunning our previous example should return the expected outcome.
const idLens = lens(prop('id'), assoc('id'))
view(idLens, user) // 1
set(idLens, 2, user) // // {id: 2, name: 'userA'}
From here on out let’s neglect our naive implementation and use Ramda instead. Ramda offers a number of very useful lens functions. The following examples rely on lens, lensProp, lensPath, lensIndex, view, set, over and common Ramda functions like compose and map. Now that we have the low level basics covered, let’s see lenses in action. The following examples will be based on the following user object.
const user = {
id: 1,
name: 'userA',
company: {
id: 12,
name: 'bar',
address: {
street: 'randomstreet',
}
},
comments: [
{id: 2, text: 'yes, this could work.', to: {id: 4}},
{id: 3, text: 'not sure.', to: {id: 12}},
{id: 4, text: 'well, maybe', to: {id: 4}},
],
}
Our previous code can be rewritten using the lensProp shorthand function, which returns a lens for getting and setting a defined field. To reiterate on our previous example.
const idLens = lensProp('id')
view(idLens, user) // 1
set(idLens, 2, user) // user = {id: 2, name: 'userA', ...}
Let’s see how we can update nested properties, by retrieving the companies street address. Ramda’s lensPath comes handy in this specific case.
const userCoStrLens = lensPath(['company', 'address', 'street'])
view(userCoStrLens, user) // randomstreet
Updating the street name can be achieved as easily, without mutating the user object.
const updateUserAddress = set(userCoStrLens, 'Teststreet', user)
Updating array values
We can even update specific array values by using lensIndex.
const firstItem = lensIndex(0)
Same can be achieved with lensPath, which can handle keys as well indices.
const firstCommentLensId = lensPath(['comments', 0, 'id'])
view(firstCommentLensId, user) // 2
The firstCommentLensId can also be applied to update that comment id using the set function.
set(firstCommentLensId, 12, user)
Using over to apply a function
We have seen view and set in action, but we haven’t touched a third interesting function called over. With over we can apply a function to update the field of an object or array. Imagine we wanted to uppercase the first comment.
const firstCommentTextLens = lensPath(['comments', 0, 'text'])
over(firstCommentTextLens, toUpper, user)
Composition
Another nice and interesting fact is that lenses compose.
const addressLens = lensProp('address')
const streetLens = lensProp('street')
const addressStreetLens = compose(addressLens, streetLens)
A noteworthy aspect is that they compose from left to right. We can also mix and match lensIndex and lensProp just like in the following example.
const commentLens = lensProp('comments')
const firstIndexLens = lensIndex(0)
const idLens = lensProp('id')
compose(commentLens, firstIndexLens, idLens)
By using map we can even convert all comments to uppercase.
over(commentLens, map(over(textLens, toUpper)), user)
Real World
You still might be asking yourself if this is worth all the trouble, when one can simply update or access an object directly. One use case that comes to mind is that we can pass a lens function around, enabling to retrieve values from a state object without having to know about how this object is actually structured. Another is that we never directly mutate our object or array but get a shallow copy in return.
Lenses should be used when we need to update or extend an object without wanting to break other implementations or where we don’t have access to libraries like immutable.js f.e.
Using lenses when rendering a view for example, where you need to format the given data, is one good example.
const getComments = view(lensProp('comments'))
const getText = view(textLens)
const textToUpper = over(textLens, toUpper)
const allTextToUpper =
compose(map(compose(getText, textToUpper)), getComments)
Now we can call allTextToUpper which ensures that all are comments are in capital letters minus mutating our original user object.
const renderView = user => (
<div id="comments">
{map(comment => (<div>{comment}</div>), allTextToUpper(user))}
</div>
)
Outro
We should have covered the basics with this write-up.
If you want to read more about lenses and see further examples, I would recommend reading Lenses with Immutable.js by Brian Lonsdorf and Lenses and Virtual DOM Support Open Closed by Hardy Jones.
If you have any feedback please leave a comment here or on Twitter.
Top comments (1)
Thanks for the nice explanations here. I've once wondered what were good use cases for lens, now I can see the interest!