Beliebte Suchanfragen
//

Zooming on lenses, with lenses in the JS land

12.2.2018 | 7 minutes of reading time

Functional lenses or simply lenses are functions that point to a specific part of the data structure. Lenses help us to focus on just the individual entity in the complex data structure by providing a functionality for accessing as well as updating the entity without mutation of the original structure.

Let’s get started.

1const object = {
2    property: 'my value'
3}
4 
5object.property = 'another value'

The example above shows the way how commonly we mutate the object state, and there is nothing wrong with this approach, but if we want to stay pure this definitely is not a way to go. By pure I mean to avoid dirty mutations.

Let’s compare this example with the lenses one.

1const object = {
2    property: 'my value'
3}
4 
5const valProp = lensProp('property')
6 
7const obj = setProp(valProp, 'another value', object)

We have one function to focus on the specific property of the data structure the “lensProp”, and one to set the value on lens property in this case the “setProp” function. And the usage, we just applied the “lensProp”, new value, and the object which we want to update, then the function call just returned the new object with an updated property.

Lenses are so powerful but definitely, more verbose. I would suggest not rushing to use them in every possible situation, they are not the solution to every problem if there is no need for immutability you will not gain much if you use them.

Under the hood of the lenses

We will write the most basic lenses library, just to see that lenses don’t use black magic whatsoever. I would not tell you to do the same in the real world situation because in the npm land there is much more mature and robust implementations. Most of these lens libraries from npm would possibly suit all your needs. Moreover, they tackled even the tiny edge cases which we will ignore in our implementation. I suggest taking a look at the ramda library, ramda includes a ton of pure immutable-free functions including everything you need for lensing.

As what is said in the introduction, lenses provide easy functionality for accessing as well as updating the state of the entity, In other words, lenses are something like a getters and setters function, but much more flexible and reusable.
So, let’s dive into creating these functions, first let’s create a lens function.

1const lens = (getter, setter) => ({
2  getter,
3  setter 
4})

The lens one is dead simple, but now we need those two getter and setter functions, also I will use the same names like in ramda library, just that in the end hopefully every example used here can be puggled with ramda implementation rather than using this one.
Now the getter function.

1const prop = key =>
2  object =>
3    object[key]

The prop function will act as our “generic” getter also as we can see we “curried” the function just that we can partially provide arguments.
Now we can do something like this

1const objA = {name: 'objA'}
2const objB = {name: 'objB'}
3 
4// prop will wait for data, the last argument to the function to be executed
5const nameProp = prop('name')
6 
7console.log(
8  nameProp(objA),
9  nameProp(objB)
10)

After we are done with the getter function, we should implement the setter too.

1const assoc = key =>
2  value =>
3    object =>
4      Object.assign({}, object, {[key]: value})

Simple as getter, the setter function will just clone the object with provided new value, so that process of setting the new value stays immutable.

And small example how would we use the setter-assoc function.

1const objA = {name: 'objA'}
2const objB = {name: 'objB'}
3 
4const setName = assoc('name')
5 
6console.log(
7  setName('new objA name')(objA),
8  setName('name objB')(objB)
9)

Now finally we can feed the lens function with our getter and setter functions, but still, the lens function will be rather worthless if we don’t write a few more functions to work with our lens. We need to write functions for viewing, changing and possibly for applying the function to the “focused” value.

1const view = (lens, obj) =>
2    lens.getter(obj)
3 
4const set = (lens, val, obj) => 
5    lens.setter(val)(obj)
6 
7const over = (lens, fmap, obj) => 
8    set(lens, fmap(view(lens, obj)), obj)

They are pretty basic, right?
Then we should try it on the simple example, but before we dive deep into the example let’s take look at how would we use our lense function.

1const objLens = lens(prop('property'), assoc('property'))

The prop and assoc that we passed to a lens function are using the same argument, and every time we want to write lens like that we would need to pass the prop and assoc functions. But we can reduce that boilerplate by creating the new function which will cut those two. And as before we will use the same name as ramda does to call our functions

1const lensProp = property =>
2  lens(prop(property), assoc(property))

Now back to the example :

1// plain data object
2const object = {
3  property: 'some value',
4  issue: {
5    name: 'nested',
6    deep: [
7      { name: "Brian", lastName: "Baker" },
8      { name: "Greg", lastName: "Graffin" }, 
9      { name: "Greg", lastName: "Hetson" }]
10  }
11}
12 
13// variadic pretty object console.log
14const logObjs = (...objs) =>
15    objs.forEach(obj => console.log(JSON.stringify(obj, null, 2)))
16 
17// curried first class Array.property.map() 
18const mapOver = fn =>
19  data =>
20    data.map(fn)
21 
22// creat a couple of lenses
23const issueLens = lensProp('issue')
24const nameLens = lensProp('name')
25 
26// first class to string to upper case
27const toUpper = str => str.toUpperCase()
28 
29const lensOverToUpperCase = lens => 
30  str =>
31    over(lens, toUpper, str)
32 
33const nameToUpper = lensOverToUpperCase(nameLens)
34 
35const massagedObject = 
36  set(issueLens, over(lensProp('deep'), 
37     mapOver(nameToUpper), view(issueLens, object)), object)
38 
39// log the result
40logObjs(
41  object,
42  massagedObject
43)

Looks pretty interesting but still “massagedObject” becomes unreadable due to multiple levels of data, and it contains a ton of repetition, as we can see two times we passed the “object” and “issueLens”. Most of the lens libraries solve this problem by providing the way to see through multiple levels of the data structure, ramda contains a lensPath function which accepts an array of properties, the path to a specific property in the structure. Now we just need the lensPath one, and I promise it will be the last one. Shall we implement that one too?

1const lensPath = path => lens(pathView(path), pathSet(path))

And that is it, simple right? But, But, we still need those pathView and pathSet functions implemented, they are just like a prop and assoc functions but they will work with an array of properties instead of single property.

1const pathView = paths =>
2  obj => {
3    let maybeObj = obj;
4    paths.forEach((_, index) => 
5      maybeObj = maybeObj[paths[index]]) 
6    return maybeObj
7}
8 
9const pathSet = path => 
10  value =>
11    object => {
12      if (path.length === 0) 
13        return value
14 
15      const property = path[0];
16      const child = Object.prototype.hasOwnProperty.call(object, property) ?
17        object[property] :
18        Number.isInteger(path[1]) ? [] : {}
19 
20      val = pathSet(path.slice(1))(value)(child);
21 
22     return Array.isArray(object) ? 
23        Object.assign([...object], {[property]: value}) :
24        assoc(property)(value)(object)
25    }

And now our “massagedObject” from example above could be written in a lot more readable fashion, without repetition, like this

1const massagedObject = over(lensPath(['issue','deep']),
2    mapOver(nameToUpper), object)

Finally, it started to look elegant and it will do a job 🙂

And also everything we write above is available here gist, JS lens example

All of this is interesting but could it be applied in a real-world situation?

Offcourse it can be used in a real-world situation, for example, to manipulate a deeply nested state value in React component.

if for some weird reason you have a state like this

1class App extends Component {
2 
3  constructor(props){
4    super(props)
5    this.state = {
6      obj:{ nested: { name: 'weird state', more: { text: 'something'} } }
7    }
8  }
9...
10}

And if we wanted to change the nested text field we would write a function like this

1change = event => {
2    this.setState({
3      obj: { nested: { more: { text: this.toUpper(event.target.value)}}}
4    })
5  } //WRONG

This approach doesn’t work because the setState object will not merge the nested objects and all of the other data except the ‘text’ property value will be lost so after the state update we wouldn’t have the name property.

Actually, it’s a huge pain to update the text state value because first, we need to update property “obj”, which needs an updated property “nested” and also “nested” needs the updated “more” that will eventually contain our updated “text” value. And this is how that huge pile of mess will look like.

1change = event => {
2    this.setState({
3      obj: {...this.state.obj,
4         nested: {...this.state.obj.nested,
5           more: {...this.state.obj.nested.more,
6             text: this.toUpper(event.target.value)}}}
7    })
8  }

Also, this one uses the object spread syntax which is not jet implemented in the language but thanks to babel we can use it right now, and you will agree this still looks horrible even with object spread syntax, don’t even try to imagine how it would look with Object.assign() syntax.

Let’s refactor the change function to use lenses and see where lenses shine brightest,

1change = event => {
2    const value = event.target.value
3    this.setState((state) => 
4        set(lensPath(['obj', 'nested', 'more', 'text']), value, state))
5  }

And that’s everything you need to safely update the deeply nested state value.

share post

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.