Skip to content

Instantly share code, notes, and snippets.

@gatlin
Last active February 11, 2024 04:27
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gatlin/264d49cf322aef31beccf680093bef38 to your computer and use it in GitHub Desktop.
Save gatlin/264d49cf322aef31beccf680093bef38 to your computer and use it in GitHub Desktop.
Simple implementation of lenses in JavaScript

What is a lens?

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:

  1. Some structure (eg an Object, an Array, etc);
  2. An index specifying some piece of the structure; and
  3. 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.

'use strict';
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;
};
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 lens = (key) => (fn) => (s) => fn(s[key]).map((v) => replace(key, v, s));
let get = (l) => (s) => extract.compose(l(Getter))(s);
let over = (l) => (f) => (s) => extract.compose(l(Setter.compose(f)))(s);
let constant = (x) => (y) => x;
let set = (l) => (v) => (s) => over(l)(constant(v))(s);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment