Introduction to Lenses in JavaScript


When I was reading Eric Elliott’s article on Lenses , I was curious about how such beautiful magic can be fully implemented in JavaScript. It was a tough exploration. Many of the tutorials online are about Haskell, which cannot be easily translated to JavaScript. I read the source code of Ramda and finally grokked how it works.

In order to understand lenses, you need to first understand some prerequisite functional programming concepts. I’ll walk you through the essential ones.

Currying

Currying is a technique that you use to delay function execution. It enables you to feed a function one argument at a time. For example, with function const add = (x, y) => x + y, you need to feed the function two numbers at once in order to perform the calculation. What if we only have the first argument value available and want to store it in the add function context, and later call it when we’re ready? Like this:

const add = (x, y) => x + y

// we get a value v from somewhere, and we want to store it in the add function context
const addV = add(v)

// and later we get a value w, we can finally perform the addition
addV(w)

The example is trivial, but you get the idea. Why do we want to store a value inside the context of a function? This is how we combine value with behaviors in functional programming. You’ll see what I mean once we get to the lens part.

Here’s how we implement currying in JavaScript:

const curry =
  fn =>
  (...args) =>
    args.length >= fn.length ? fn(...args) : curry(fn.bind(undefined, ...args))

Functors

Functors are just data types that you can map over. Think about JavaScript array, you can map over an array and transform the values inside of it. Functors are similar, they are ‘boxes’ that hold computational context. Let’s see a few examples:

const Box = x => ({
  value: x,
  map: f => Box(f(x))
})

Box is a functor. We can’t see what use it has yet. But let’s observe some properties of it.

When you call Box with a value, the value is stored in a context, which is the returned object. After that, you can transform the value by mapping over the context however you want.

Box(2)
  .map(x => x + 1)
  .map(x => x * 2)

We are stacking up computations by mapping. That’s all we need to know about functors for now.

Implementing lenses

Let’s put what we just learned into use and implement lenses!

First, we define functional getters and setters. They’re pretty simple.

const prop = curry((k, obj) => (obj ? obj[k] : undefined));

const assoc = curry((k, v, obj) => ({ ...obj, [k]: v }));

const obj = {a: 1, b: 2};
prop(‘a’)(obj) // 1
assoc(a)(3)(obj) // {a: 3, b: 2}

Then we define a function to make lenses:

const makLens = curry(
  (getter, setter) => functor => target =>
    functor(getter(target)).map(focus => setter(focus, target))
)

I know how you feel about this cryptic function. Just ignore it, for now. We’ll come back to it when we’re ready.

Let’s simplify the makeLens function a bit and make the getter and setter ready:

const lensProp = k => makeLens(prop(k), assoc(k))

Here come the mighty functors:

const getFunctor = x =>
  Object.freeze({
    value: x,
    map: f => getFunctor(x)
  })

const setFunctor = x =>
  Object.freeze({
    value: x,
    map: f => setFunctor(f(x))
  })

You can see they are very similar as the Box functor we’ve defined earlier. We use Object.freeze() to prevent mutations, as mutations in functional programming are forbidden.The getFunctor just ignores the mapping function and always returns the initial value, seems like very silly.

Now, we’re finally ready to make something useful!

const view = curry((lens, obj) => lens(getFunctor)(obj).value)

const sample = {foo: {bar: {ha: 6}}}
const lensFoo = lensProp('foo')
view(lensFoo, sample) // => {bar: {ha: 6}}

Yay! After so much cryptic code, we are finally able to get a value out from an object! 🤣

Before we continue, let’s reason about the above code.

When we call lens with getFunctor, and later call getFunctor with a value pulled out by the getter function provided earlier, we get a very simple computational context. In the case of getFunctor, this context just provides the initial value and ignores mapping operations later.

Let’s look at set operations:

const over = curry((lens, f, obj) => lens(y => setFunctor(f(y)))(obj).value)

const always = a => b => a
const set = curry((lens, val, obj) => over(lens, always(val), sample))
set(lensFoo, 5, sample) // => {foo: 5}

This time, the setFunctor doesn’t ignore mapping operations, so the operation map(focus => setter(focus, target)) from the makeLens function will be performed, giving us the opportunity to transform the value returned by the getter function.

The always function looks silly, but look at how we use it to implement set, it’s a useful one!

The power of lenses

Based on the examples I gave earlier, it’s not obvious how useful lenses can be. In JavaScript, we can read and set values in objects very easily. It seems like there’s no need for all the hassles!

The power of lenses comes from their composability.

Let’s first define a compose function:

const compose =
  (...fns) =>
  args =>
    fns.reduceRight((x, f) => f(x), args)

Then we can read the inner values of the sample object like this:

const lensFoo = lensProp('foo')
const lensBar = lensProp('bar')
const lensFooBar = compose(lensFoo, lensBar)

view(lensFooBar, sample) // => {ha: 6}

We can write a helper function to help us to get the inner lens:

const lensPath = path => compose(...path.map(lensProp))
const lensHa = lensPath(['foo', 'bar', 'ha'])

const add = a => b => a + b

view(lensHa, sample) // => 6
over(lensHa, add(2), sample) // => {foo: {bar: {ha: 8}}}

Ok, I know you must be thinking: how’s this powerful? I can achieve the same thing using lodash _.get()! Stay patient!

Let’s consider another example. Say we have an app that lets users log their body weight. Users can fill in with both killograms and pounds. To avoid data redundancy, we only stores user records in killograms. Here’s a user record:

const user = {weightInKg: 65}

We know that we have the following conversion rate between kg and lb:

const kgToLb = kg => 2.20462262 * kg
const lbToKg = lb => 0.45359237 * lb

If we want to display the user’s weight in pounds, we can get the weight in kg, and convert it to lb, which is a very straightforward approach. But we can do it more smoothly with lenses:

const weightInKg = lensProp('weightInKg')
const lensLb = lens(kgToLb, lbToKg)
const inLb = compose(lensLb, weightInKg)

view(inLb, user) // -> 143.3

This looks neat. We provide different lenses to the view function, it will return us a tailored result, and the target data remains untouched.

Suppose that the user one day adds 5 pounds to his record, the data can be updated easily like this:

over(inLb, add(5), user) // -> 67.27

Wow! That reads like plain English. Without digging into the implementation details, we can interpret the operation as this: under this lens, I want to add 5 to the user record. I don’t care in what unit the stored data may be, just do it for me! The power of declarative programming really shines in this example.