DEV Community

loading...

Lenses A.K.A. composable getters and setters

vonheikemen profile image Heiker Updated on ・9 min read

Puedes leer la versión en español aquí.

This time around we will figure out what are lenses, how do they look like in javascript and hopefully will build a mostly adequate implementation.

Let us first take a step back and ask.

What are getters and setters?

This are functions that have one goal, they get or set value. But of course that is not the only thing they are good for. Most use cases I've seen involve triggering a side-effect when a value changes or put some validations to prevent undesired behavior.

In javascript you could make them explicit by doing something like this.

function Some() {
  let thing = 'stuff';

  return {
    get_thing() {
      // you can do anything in here
      return thing;
    },
    set_thing(value) {
      // same in here.
      thing = value;
    }
  }
}

let obj = Some();
obj.get_thing(); // => 'stuff'

obj.set_thing('other stuff');

obj.get_thing(); // => 'other stuff'

Or you could make then implicit.

let some = {};

Object.defineProperty(some, 'thing', {
  get() {
    return 'thing';
  },
  set(value) {
    console.log("can't touch this");
  }
});

some.thing // => 'thing'
some.thing = 'what?';

//
// can't touch this
//

some.thing // => 'thing'

But what is so wrong in there that some people feel the need to use something like lenses?

Let's start with that second example. I can tell you that some people don't like magical things, just the thought of a function being executed without their knowledge is bad enough.

The first example is bit more interesting. Let's see it again.

obj.get_thing(); // => 'stuff'

obj.set_thing('other stuff');

obj.get_thing(); // => 'other stuff'

You use get_thing you get stuff, so far so good. But here is the problem you use it again in exactly the same way and yet you get other stuff. You kinda have to keep track of the last call to set_thing in order to know what you would get. We don't have the ability to predict the result from get_thing, you can't be 100% sure what it will do without looking around (or knowing) others parts of the code.

Is there a better way?

I wouldn't say better. Let us just try lenses, you can decide later if you like them or not.

What do we need? Lenses are a functional programing thing so the first thing we will do is create helper functions. This will be the first version of getters and setters.

// Getter
function prop(key) {
  return obj => obj[key];
}

// Setter
function assoc(key) {
  return (val, obj) => Object.assign({}, obj, {[key]: val});
}

Now for the "constructor."

function Lens(getter, setter) {
  return { getter, setter };
}

// That is it.

You'll notice that Lens does absolutely nothing, I'm doing that on purpose. You can already tell that most of the work is in the getter and setter. Your lens are going to be as robust as your getter and setter implementations.

Now we need to make them do something, we will make three little functions.

view: gets a value

function view(lens, obj) {
   return lens.getter(obj);
}

over: transforms a value using a callback

function over(lens, fn, obj) {
  return lens.setter(
    fn(lens.getter(obj)),
    obj
  );
}

set: replaces a value

function always(val) {
  return () => val;
}

function set(lens, val, obj) {
  // don't you love reusability?
  return over(lens, always(val), obj);
}

It's time for a test drive.

Let's say we have an object named alice.

const alice = {
  name: 'Alice Jones',
  address: ['22 Walnut St', 'San Francisco', 'CA'],
  pets: { dog: 'joker', cat: 'batman' }
};

We'll start with something simple, inspect the values. This is how you would do it.

const result = view(
  Lens(prop('name'), assoc('name')),
  alice
);

result // => "Alice Jones"

I see you're not impressed and that's fine. I just wrote a lot characters just to get a name. But here is the thing, these are standalone functions. We can always compose and create new ones. Let's start with that Lens(prop, assoc) bit, we will put that in a function because we will use it a lot.

function Lprop(key) {
  return Lens(prop(key), assoc(key));
}

And now...

const result = view(Lprop('name'), alice);

result // => "Alice Jones"

You could even take it one step further and make a function that just expects the object that holds the data.

const get_name = obj => view(Lprop('name'), obj);

// or with partial application

const get_name = view.bind(null, Lprop('name'));

// or using a curry utility.
// view = curry(view);

const get_name = view(Lprop('name'));

// and you can also do this with `set` and `over`

Enough of that. Going back to our test, let's try over. Let's transform the name to uppercase.

const upper = str => str.toUpperCase();
const uppercase_alice = over(Lprop('name'), upper, alice);

// see?
get_name(uppercase_alice) // => "ALICE JONES"

// sanity check
get_name(alice)           // => "Alice Jones"

It's set's turn.

const alice_smith = set(Lprop('name'), 'Alice smith', alice);

get_name(alice_smith) // => "Alice smith"

// sanity check
get_name(alice)       // => "Alice Jones"

That's all nice but the name is just one property, what about nested object keys or arrays? Ah, you see now that is where it gets awkward with our current implementation. Right now you could do the following.

let dog = Lens(
  obj => prop('dog')(prop('pets')(obj)),
  obj => assoc('dog')(assoc('pets')(obj))
);

view(dog, alice); // => "joker"

// or bring a `compose` utility

dog = Lens(
  compose(prop("dog"), prop("pets")),
  compose(assoc("dog"), assoc("pets"))
);

view(dog, alice); // => "joker"

I hear you. Don't worry, I wouldn't let you write stuff like that. It is because of situations like this one that people say stuff like "just use Ramda" (and those people are right) But what makes ramda so special?

Making it special

If you go to ramda's documentation and search "lens" you'll see that they have a lensProp function which is basically our Lprop. And if you go to the source you'll see something like this.

function lensProp(k) {
  return lens(prop(k), assoc(k));
}

Look at that. But now the comments on their source and documentation suggest that it also works with just one property. Let's go back to our "lens" search on their site. Now we will check that curious lensPath function. It is exactly what we want. Once again we check out the source.

function lensPath(p) {
  return lens(path(p), assocPath(p));
}

// Welcome to functional programming, y'all.

The secret sauce it's made of other functions that don't have any specific ties to lenses. Isn't that just nice?

What is in that path function? Let's check it out. I'll show you a slightly different version, but it works just the same.

function path(keys, obj) {
  if (arguments.length === 1) {
    // this is for currying
    // they do this by wrapping `path`
    // with a helper function
    // but this is what happens
    // they return a function that remembers `keys`
    // and expects `obj`
    return path.bind(this, keys);
  }

  var result = obj;
  var idx = 0;
  while (idx < keys.length) {
    // we don't like null
    if (result == null) {
      return;
    }

    // this is how we get the nested keys
    result = result[keys[idx]];
    idx += 1;
  }

  return result;
}

I'll do the same with assocPath. For this one they make use of some internal helpers but again this is what happens.

function assocPath(path, value, obj) {
  // again with the currying stuff
  // this is why they have a helper function
  if (arguments.length === 1) {
    return assocPath.bind(this, path);
  } else if (arguments.length === 2) {
    return assocPath.bind(this, path, value);
  }

  // check for an empty list
  if (path.length === 0) {
    return value;
  }

  var index = path[0];

  // Beware: recursion ahead.
  if (path.length > 1) {
    var is_empty =
      typeof obj !== 'object' || obj === null || !obj.hasOwnProperty(index);

    // if the current object is "empty"
    // we need to create a new one
    // otherwise we pick the object at `index`
    var next = is_empty
      ? typeof path[1] === 'number'
        ? []
        : {}
      : obj[index];

    // we start again the process
    // but now with a reduced `path`
    // and `next` as the new `obj`
    value = assocPath(Array.prototype.slice.call(path, 1), value, next);
  }

  // the base cases
  // we either have to copy an array
  // or an object
  if (typeof index === 'number' && Array.isArray(obj)) {
    // make a 'copy' of the array
    var arr = [].concat(obj);

    arr[index] = value;
    return arr;
  } else {
    // old school 'copy'
    var result = {};
    for (var p in obj) {
      result[p] = obj[p];
    }

    result[index] = value;
    return result;
  }
}

With our new found knowledge we can create an Lpath function and improve Lprop.

function Lpath(keys) {
  return Lens(path(keys), assocPath(keys));
}

function Lprop(key) {
  return Lens(path([key]), assocPath([key]));
}

Now we can do more stuff, like playing with alice pets.

const dog_lens = Lpath(['pets', 'dog']);

view(dog_lens, alice);     // => 'joker'

let new_alice = over(dog_lens, upper, alice);
view(dog_lens, new_alice); // => 'JOKER'

new_alice = set(dog_lens, 'Joker', alice);
view(dog_lens, new_alice); // => 'Joker'

All of this works great but there is just one tiny detail, the lenses that the current constructor creates aren't composable. Imagine that we have three lenses from different files or something and we want to combine them like this.

compose(pet_lens, imaginary_lens, dragon_lens);

This wouldn't work because compose expects a list of functions and our lenses are objects. But we can fix this (in a very funny way) with some functional programming trickery.

Let's start with our lenses constructor. Instead of returning an object we are going to return a "curried" function that takes a callback, an object and returns a Functor (a thing that has map method and follows this rules)

function Lens(getter, setter) {
  return fn => obj => {
    const apply = focus => setter(focus, obj);
    const functor = fn(getter(obj));
    return functor.map(apply);
  };
}

What's with the a fn => obj => stuff? That is going to help us with our compose situation. Now after you provide the getter and setter you get a function, and that is what makes compose happy.

And functor.map? That is going to make sure that we can still use a lens as unit (like Lprop('pets')) but also a part of a chain using compose.

In case you are wondering what the good folks at ramda do different, they use their own bulletproof implementation of map.

Now we modify view and over. Starting with view.

function view(lens, obj) {
  const constant = value => ({ value, map: () => constant(value) });
  return lens(constant)(obj).value;
}

That constant thing might look like is too much, but it does the job. Things can get crazy in those compose chains, that just makes sure the value you want stays safe.

What about over? It will do almost the same thing, except that in this case we do need to use the setter function.

function over(lens, fn, obj) {
  const identity = value => ({ value, map: setter => identity(setter(value)) });
  const apply = val => identity(fn(val));
  return lens(apply)(obj).value;
}

And now we should have a mostly adequate Lens implementation. The whole thing without dependencies (path and assocPath) should look like this.

function Lens(getter, setter) {
  return fn => obj => {
    const apply = focus => setter(focus, obj);
    const functor = fn(getter(obj));
    return functor.map(apply);
  };
}

function view(lens, obj) {
  const constant = value => ({ value, map: () => constant(value) });
  return lens(constant)(obj).value;
}

function over(lens, fn, obj) {
  const identity = value => ({ value, map: setter => identity(setter(value)) });
  const apply = val => identity(fn(val));
  return lens(apply)(obj).value;
}

function set(lens, val, obj) {
  return over(lens, always(val), obj);
}

function Lprop(key) {
  return Lens(path([key]), assocPath([key]));
}

function Lpath(keys) {
  return Lens(path(keys), assocPath(keys));
}

function always(val) {
  return () => val;
}

But can you believe me if I said it works? You shouldn't. Let's make some tests. We'll bring back alice and add her sister calie.

const alice = {
  name: "Alice Jones",
  address: ["22 Walnut St", "San Francisco", "CA"],
  pets: { dog: "joker", cat: "batman", imaginary: { dragon: "harley" } }
};

const calie = {
  name: "calie Jones",
  address: ["22 Walnut St", "San Francisco", "CA"],
  pets: { dog: "riddler", cat: "ivy", imaginary: { dragon: "hush" } },
  friend: [alice]
};

And because we planned ahead we have some lenses already available.

// some generic lens
const head_lens = Lprop(0);

// specific lens
const bff_lens = compose(Lprop('friend'), head_lens); 
const imaginary_lens = Lpath(['pets', 'imaginary']);

Say that we want to do something with their dragons, all we have to do is compose.

const dragon_lens = compose(imaginary_lens, Lprop('dragon'));

// just for fun
const bff_dragon_lens = compose(bff_lens, dragon_lens); 

// demo
const upper = str => str.toUpperCase();

// view
view(dragon_lens, calie);         // => "hush"
view(bff_dragon_lens, calie);     // => "harley"

// over
let new_calie = over(dragon_lens, upper, calie);
view(dragon_lens, new_calie);     // => "HUSH"

new_calie = over(bff_dragon_lens, upper, calie);
view(bff_dragon_lens, new_calie); // => "HARLEY"

// set
new_calie = set(dragon_lens, 'fluffykins', calie);
view(dragon_lens, new_calie);     // => "fluffykins"

new_calie = set(bff_dragon_lens, 'pumpkin', calie);
view(bff_dragon_lens, new_calie); // => "pumpkin"

So we just manipulated a deeply nested object property by composing lenses. If you're not excited then I don't know what to tell you. We just solve a problem by composing functions!

These things can be hard to sell because they require for you to write in a certain style in order to make the most out of it. And for people who write javascript there are libraries out there that solve the same problem in a more convenient way, or at least in a way that is more suitable for their style.

Anyway, if you're still interested in seeing lenses in a non trivial context checkout this repository, it is a real world example app (kinda like medium.com clone) that uses hyperapp to handle the frontend. In it the author chose to use lenses to handle state of the app.

Sources

Discussion (0)

pic
Editor guide