Note: you can copy the file 2-lenses.js
below over to repl.it and
play along from home!
A lens in programming, much like a lens in the real world, allows you to focus in on a small part of a larger whole and then do something with or to that part.
You may also think of it as breaking a piece out, manipulating the piece, and then reconstructing the whole.
That "something" can be changing the value or merely passing the value elsewhere to be read.
Thus, lenses are both getters and setters. Here's an example of the code we'll be implementing:
let foo = {
bar: 5,
baz: [ 'quux', 'axolotl' ]
};
let barL = lens('bar');
let bazL = lens('baz');
let bazAt = (index) => bazL.compose(lens(index));
let axolotl = get(bazAt(1))(foo); // 'axolotl'
set(barL)(7)(foo);
set(bazAt(1))('potrzebie')(foo);
console.log(foo); // { bar: 7, baz: ['quux','potrzebie'] }
Some key things to note:
- Lenses are composable; and
- Lenses can be used to both set and get parts of an object.
More technically, a lens is a function of three arguments:
- Some structure (eg an Object, an Array, etc);
- An index specifying some piece of the structure; and
- A function which transforms that piece
The lens then returns a new structure, with its transformed piece inside.
First, let's define some things we know we'll need, to wit:
- A way to replace part of a structure at a certain key with a new value;
- A way to compose functions.
if(!Function.prototype.compose) {
Function.prototype.compose = function(g) {
let f = this;
return function(x) {
return f(g(x));
};
};
}
let replace = function(key, value, thing) {
thing[key] = value;
return thing;
};
Now, on to lenses. A lens breaks a part from a whole, passes the part to a function, and puts the result back into the whole. Intuitively, then, whether or not a lens is used as a setter or a getter rests on that function we pass in.
This function we pass to the lens must return something we can put back in the whole -- or at the very least, lead us to something which fits the bill.
My strategy will be to write two functions which both take a value and return an object with two properties:
- a reference to the wrapped-up value; and
- a function to let us transform that value.
Some of you may have already guessed it, but I'm basically defining two functors:
let Setter = (x) => Object.seal({
value: x,
map: (f) => Setter(f(x))
});
let Getter = (x) => Object.seal({
value: x,
map: (f) => Getter(x)
});
let extract = (s) => s.value;
let setTest = Setter(5);
console.log(extract(setTest.map((x) => x*2))); // 10
let getTest = Getter(5);
console.log(extract(getTest.map((x) => x*2))); // 5
The key here is uniformity: both Setter and Getter wrap up a value and allow me to at least pretend I'm transforming the value inside. Setter lets me do it; Getter doesn't. Simple.
The uniform way to transform the value inside is the map()
method.
Armed with these two functions, we can finally write the lens function:
let lens = (key) => (fn) => (s) => fn(s[key]).map((v) => replace(key, v, s));
Note that I'm writing this function in curried style. This is mostly for stylistic reasons but it does make the code more readable and usable later on. You'll see.
So now we can create lenses!
let myself = {
name: 'gatlin',
age: 27,
dogs: [{
name: 'louie', breed: 'pug'
}, {
name: 'pugzy', breed: 'pug'
}]
};
let nameL = lens('name'); // <-- this is why we curried the function
let ageL = lens('age');
let dogsL = lens('dogs');
let dogAt = (idx) => dogsL.compose(lens(idx));
let rename = (thing, newName) => set(nameL)(newName)(thing);
So how do we use these fancy new lenses? Lets first tackle reading, or "getting", a value from a structure using a lens:
let get = (l) => (s) => extract.compose(l(Getter))(s);
This is a function which takes a lens and a structure. The l
variable is a
lens, which means that it is going to need a transformation function and
then a value. so l(Getter)
returns a function that needs a structure given
to it (which would be s
). However, ultimately we don't want a Getter
object, we want the value inside it. So we compose extract
with all this
to retrieve the final value.
As for setting, that's a special case of a more general operation:
transforming a part of a whole. Replacement is one kind of transformation.
For historical reasons let's define this more general kind of mutation as
over
:
let over = (l) => (f) => (s) => extract.compose(l(Setter.compose(f)))(s);
Let's unpack this. over
requires a lens, a transformation function, and a
structure. We compose Setter
and our transformation function, creating a
Setter
containing the new value we want to put back in the whole. Then we
pass this resulting function to our lens l
, creating a new function asking
for a structure. And again, as with Getter
, we ultimately want the object
back, not the Setter
object, so we compose extract
with all this.
Pause and re-read this a few times if you need to; I'll wait.
So actually setting a value is a special case of over
where our
transformation function takes the value we want to set, the original value,
and returns the value we want to set. This function is called constant
:
let constant = (x) => (y) => x;
let set = (l) => (v) => (s) => over(l)(constant(v))(s);
Now let's try out our lenses!
console.log(myself); // "{ name: 'gatlin', age ... }"
rename(myself, 'Gatlin');
console.log(get(nameL)(myself)); // "Gatlin"
set(
// Lenses compose!
dogAt(0).compose(nameL)
)('Louie')(myself); // And now the dog has a capitalized name
// But we can also retrieve a part and use existing lens-based functions on them!
let pugzy = get(dogAt(1))(myself);
rename(pugzy,'Pugzy');
console.log(myself); // Run it for yourself!
Anyway that's the basics of lenses. There's a whole rabbit hole you can get lost in if you want with this, but this should be enough to interest you.