DEV Community

A. Sharif
A. Sharif

Posted on

An Introduction Into Lenses In JavaScript

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'}
Enter fullscreen mode Exit fullscreen mode

To access the user id we would write something like the following.

user.id // 1
Enter fullscreen mode Exit fullscreen mode

Updating could be accomplished like this f.e.

user.id = 2
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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),
  })
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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}},
  ],
}
Enter fullscreen mode Exit fullscreen mode

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', ...}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Updating the street name can be achieved as easily, without mutating the user object.

const updateUserAddress = set(userCoStrLens, 'Teststreet', user)
Enter fullscreen mode Exit fullscreen mode

Updating array values

We can even update specific array values by using lensIndex.

const firstItem = lensIndex(0)
Enter fullscreen mode Exit fullscreen mode

Same can be achieved with lensPath, which can handle keys as well indices.

const firstCommentLensId = lensPath(['comments', 0, 'id'])
view(firstCommentLensId, user) // 2
Enter fullscreen mode Exit fullscreen mode

The firstCommentLensId can also be applied to update that comment id using the set function.

set(firstCommentLensId, 12, user)
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

Composition

Another nice and interesting fact is that lenses compose.

const addressLens = lensProp('address')
const streetLens = lensProp('street')
const addressStreetLens = compose(addressLens, streetLens)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

By using map we can even convert all comments to uppercase.

over(commentLens, map(over(textLens, toUpper)), user)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
)
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
kcouliba profile image
Coulibaly Daba Kevin

Thanks for the nice explanations here. I've once wondered what were good use cases for lens, now I can see the interest!