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.
const object = {
property: 'my value'
}
object.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.
const object = {
property: 'my value'
}
const valProp = lensProp('property')
const 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.
const lens = (getter, setter) => ({
getter,
setter
})
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.
const prop = key =>
object =>
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
const objA = {name: 'objA'}
const objB = {name: 'objB'}
// prop will wait for data, the last argument to the function to be executed
const nameProp = prop('name')
console.log(
nameProp(objA),
nameProp(objB)
)
After we are done with the getter function, we should implement the setter too.
const assoc = key =>
value =>
object =>
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.
const objA = {name: 'objA'}
const objB = {name: 'objB'}
const setName = assoc('name')
console.log(
setName('new objA name')(objA),
setName('name objB')(objB)
)
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.
const view = (lens, obj) =>
lens.getter(obj)
const set = (lens, val, obj) =>
lens.setter(val)(obj)
const over = (lens, fmap, obj) =>
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.
const 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
const lensProp = property =>
lens(prop(property), assoc(property))
Now back to the example :
// plain data object
const object = {
property: 'some value',
issue: {
name: 'nested',
deep: [
{ name: "Brian", lastName: "Baker" },
{ name: "Greg", lastName: "Graffin" },
{ name: "Greg", lastName: "Hetson" }]
}
}
// variadic pretty object console.log
const logObjs = (...objs) =>
objs.forEach(obj => console.log(JSON.stringify(obj, null, 2)))
// curried first class Array.property.map()
const mapOver = fn =>
data =>
data.map(fn)
// creat a couple of lenses
const issueLens = lensProp('issue')
const nameLens = lensProp('name')
// first class to string to upper case
const toUpper = str => str.toUpperCase()
const lensOverToUpperCase = lens =>
str =>
over(lens, toUpper, str)
const nameToUpper = lensOverToUpperCase(nameLens)
const massagedObject =
set(issueLens, over(lensProp('deep'),
mapOver(nameToUpper), view(issueLens, object)), object)
// log the result
logObjs(
object,
massagedObject
)
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?
const 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.
const pathView = paths =>
obj => {
let maybeObj = obj;
paths.forEach((_, index) =>
maybeObj = maybeObj[paths[index]])
return maybeObj
}
const pathSet = path =>
value =>
object => {
if (path.length === 0)
return value
const property = path[0];
const child = Object.prototype.hasOwnProperty.call(object, property) ?
object[property] :
Number.isInteger(path[1]) ? [] : {}
val = pathSet(path.slice(1))(value)(child);
return Array.isArray(object) ?
Object.assign([...object], {[property]: value}) :
assoc(property)(value)(object)
}
And now our “massagedObject” from example above could be written in a lot more readable fashion, without repetition, like this
const massagedObject = over(lensPath(['issue','deep']),
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
class App extends Component {
constructor(props){
super(props)
this.state = {
obj:{ nested: { name: 'weird state', more: { text: 'something'} } }
}
}
...
}
And if we wanted to change the nested text field we would write a function like this
change = event => {
this.setState({
obj: { nested: { more: { text: this.toUpper(event.target.value)}}}
})
} //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.
change = event => {
this.setState({
obj: {...this.state.obj,
nested: {...this.state.obj.nested,
more: {...this.state.obj.nested.more,
text: this.toUpper(event.target.value)}}}
})
}
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,
change = event => {
const value = event.target.value
this.setState((state) =>
set(lensPath(['obj', 'nested', 'more', 'text']), value, state))
}
And that’s everything you need to safely update the deeply nested state value.
The post Zooming on lenses, with lenses in the JS land appeared first on codecentric AG Blog.