In order to understand lenses, you need to first understand some prerequisite functional programming concepts. I’ll walk you through the essential ones.
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:
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.
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.
We are stacking up computations by mapping. That’s all we need to know about functors for now.
Let’s put what we just learned into use and implement lenses!
First, we define functional getters and setters. They’re pretty simple.
Then we define a function to make lenses:
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:
Here come the mighty functors:
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!
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:
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.
always function looks silly, but look at how we use it to implement
set, it’s a useful one!
Let’s first define a
Then we can read the inner values of the
sample object like this:
We can write a helper function to help us to get the inner lens:
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:
We know that we have the following conversion rate between kg and 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:
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:
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.