Johan
Posted on February 26, 2021
Intro
Hi,
This is my first ever blog post. At my job at a software company, I often get enthusiastic about new techniques to do programming. Sometimes I want to tell the world about it, but then there's the usual business and priorities, and then teaching new things is just the first thing that is sacrificed to be able to ship before the deadline. So I thought, to vent the new ideas, I could blog about it, and maybe get rid of the urge to tell friends and family (who are well, less interested :) ).
Real intro
So what I really want to talk about is a way I came across to mutate data structures while still adhering to immutability principles. And do this in a non-ugly way using a thing called lenses.
After reading this article I hope you will be able to do that too, without too much trouble. And you will learn what functional lenses are for and where they can (and maybe even should) be applied.
I will try to explain it using a simple example and work my way through the thought process for why lenses are invented.
Prior Knowledge
It could be useful if you would understand a bit of TypeScript, but at least JavaScript knowledge is required. Also it can be useful to know some functional programming terms like 'pure function' or 'currying'. But if you don't, you should still be able to understand this. I might post another blog about it though. If I do, I'll link it here.
It's a pre if you know the ramda library.
The problem
Say, we have this data structure:
type Students = Record<string, Student>;
const students: Students = {
abc: {
name: 'Alfred',
age: 12
},
xyz: {
name: 'Xantippe',
age: 14
}
};
Now we are asked to write a function that adds one year to a given student. In TypeScript, the definition should look like this:
type AddOneYear = (studentId: string, studentList: Students) => Students;
No checks are required. It is guaranteed that given studentId exists.
The solution
Naive solution
I'll start by showing how I'd start when I was still a junior dev.
const addOneYear: AddOneYear = (studentId, studentList) => {
studentList[studentId].age++;
return studentList;
}
Easy peasy right? What's the problem here? Maybe you already guessed.
Lets call it on the structure above.
const changedStudents = addOneYear('abc', students);
console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 13. Huh?
As you can see, the original also changed! Now this addOneYear()
function is not a pure function because it changes an argument which is a side-effect.
So.. lets make it pure and treat the input arguments as immutable.
Second try
The trick to mutate things and still be immutable is by copying the input. Luckily ES6 gave us the spread operator, which makes our lives easier when we want to copy stuff. With that in mind, lets refactor our function.
const addOneYear2: AddOneYear = (studentId, studentList) => {
const copyOfStudents = {...studentList};
copyOfStudents[studentId].age++;
return copyOfStudents;
}
A little more noise in the code, but OK enough... Lets call it!
const changedStudents = addOneYear2('abc', students);
console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 13. Huh????
Oh no it still changes the source! why does this happen?
Well, the answer is that the spread operator makes a shallow copy, not a deep copy. So now we mutate a copy of the reference of student abc.
To fix this we need to copy student abc first too... sigh
Third try
Lets fix it.
const addOneYear3: AddOneYear = (studentId, studentList) => {
const copyOfStudents = {
...studentList,
[studentId]: {
...studentList[studentId],
}
};
copyOfStudents[studentId].age++;
return copyOfStudents;
}
Does it work?
const changedStudents = addOneYear3('abc', students);
console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Yay!!
It works! Nice!
But... look what has become of our source... Can we clean this up?
Fourth try
Lets try by getting rid of the separate (mutating) ++ statement. By getting rid of ++ and replacing it with + we also make sure that we don't mutate the source. So lets see:
const addOneYear4: AddOneYear = (studentId, studentList) => {
return {
...studentList,
[studentId]: {
...studentList[studentId],
age: studentList[studentId].age + 1
}
};
}
Ok... Does it still work?
const changedStudents = addOneYear4('abc', students);
console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Yes
Still, compared to the first (wrong) example, not as easy to read (even when we used the shiny new spread operator)... And suppose a student would have an address structure with 'street' and 'city' etc, and we want to update a house number? We need to nest even deeper in that case, and the code would become a mess.
Give up on immutability for the sake of readability?
Lets not give up yet, lets try a new concept of lenses.
Introducing lenses
In the real world, lenses focus light on something so you can see sharp again with glasses or burn stuff when focusing the sun on things and upset the neighbors.
In programming world, lenses focus on data inside data structures. Lets see how ramda implemented them. You go read the docs and go like... WTF?
Ok, lets skip the docs for now and see how it helps us here.
Creating a lens
Lets create a lens that focusses on Alfred's (student 'abc') age.
import { lensPath } from 'ramda';
const alfredAgeLens = lensPath(['abc', 'age']);
Ok. So now we have an alfredAgeLens... Now what?
Using a lens to get (view) data
We can use this lens to focus on a part of a structure. Lets use it to get (view) the age:
import { view } from 'ramda';
const alfredAge = view(alfredAgeLens, students);
console.log(alfredAge); // 12
Nice. Can we also use it to change (set) data? Yes!
Using a lens to set data
Lets see how that works...
import { set } from 'ramda';
const changedStudents = set(alfredAgeLens, 13, students);
console.log(changedStudents.abc.age); // 13. Nice
console.log(students.abc.age); // 12. Hey, still good!
Ok, so you can use this to set data in an immutable way! It sure looks like we can use this to implement our function! Lets try...
Fifth try. Now using a lens
const addOneYear5: AddOneYear = (studentId, studentList) => {
const studentAgeLens = lensPath([studentId, 'age']);
return set(studentAgeLens, view(studentAgeLens, studentList) + 1, studentList);
}
Run it...
const changedStudents = addOneYear5('abc', students);
console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Nice!
Ok that's more like it! Now the logic to drill in to deep data structures is reduced to creating a lens! A lot of noise is removed, which feels great!
But looking at the code more closely, it has gotten more readable, but it is using the lens and the studentList twice. One time for viewing and another for setting... Can we make it even nicer?
Introducing over
I'm actually not sure why it's called over
. (Let me know if you do.) But we can use this function to get and set focused data at the same time!
Lets see how it works by implementing the function with it
Sixth try. Now using over
const addOneYear6: AddOneYear = (studentId, studentList) => {
const studentAgeLens = lensPath([studentId, 'age']);
return over(
studentAgeLens,
age => age + 1,
studentList
);
}
Run it...
const changedStudents = addOneYear6('abc', students);
console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Nice!
As you can see, the second argument of over
is a function that takes the focused data and returns it's new value which is then used to set the new data.
Since we are now using the lens only once, we don't need a separate variable, and we can refactor it to make it a single expression function:
const addOneYear6: AddOneYear = (studentId, studentList) =>
over(
lensPath([studentId, 'age']),
age => age + 1,
studentList
);
Oh the aesthetics! :) It almost reads like a book! "Over given students age, get its age, and add one to it. Do this for given studentList."
Almost... I'd say good enough but still a bit verbose... Can we do even better? In JavaScript/TypeScript?
Surprisingly, yes! But we are gonna need another concept: currying.
Currying intermezzo
Currying actually needs its own topic. But it applies so nicely here that I really want to use it here. So a very short description of it's principle.
I think it's best to show what it is with a little example.
const addNonCurried = (a, b) => a + b;
const addCurried = a => b => a + b;
console.log(addNonCurried(3, 4)); // 7
console.log(addCurried(3)(4)); // 7
const addThree = addCurried(3);
console.log(addThree(4)); // 7
Do you see what happens here?
addCurried
is a function that takes only 1 (!) argument. It returns a function that also takes 1 argument. When that second function is called, it returns the sum of the arguments of both functions.
When you supply only one argument, like addCurried(3)
, it is called 'partial application'. (Function application is just a different term for calling a function.)
You can also 'partially apply' the non-curried variant like this:
const addThree = b => addNonCurried(3, b);
But now you see the advantage of curried functions :)
Lets see whether we can use this knowledge in our addOneYear
example...
Using currying
First you need to know that all functions in ramda can be called in a curried and non-curried way. Also, ramda has this add
function.
So in this addOneYear
example, we want to add 1 to a year. We did this by a function like this:
const addOne = age => age + 1;
When you try to read this as a book, it says something like: addOne takes a value and adds one to it.
Now lets use ramda's add
function:
const addOne = add(1);
Ok. So how do you read that? Something like: addOne adds one.
As you can see, the name of the function addOne
almost reads the same as it's implementation now: add(1)
! So we don't need to name it, but can just use its implementation. Lets see how it turns out in our addOneYear
example:
const addOneYear7: AddOneYear = (studentId, studentList) =>
over(
lensPath([studentId, 'age']),
add(1),
studentList
);
Good! Now... can we squeeze out even more?
Yes! But then we have to make our 'addOneYear' function curried. Lets do that and see what happens:
Squeeze out more
Ok so first lets redefine the AddOneYear type to make it curried.
Before it was:
type AddOneYear = (studentId: string, studentList: Students) => Students;
Now it becomes:
type AddOneYear = (studentId: string) => (studentList: Students) => Students;
Allmost the same, right? Ok, so how to implement it?
const addOneYear8: AddOneYear = studentId => studentList =>
over(
lensPath([studentId, 'age']),
add(1),
studentList
);
The implementation is almost the same, but now it is curried...
How should we call it? Simple:
const changedStudents = addOneYear8('abc')(students);
console.log(changedStudents.abc.age); // 13
console.log(students.abc.age); // 12
Instead of a ',' between the arguments, we now put ')(' there.
So, 'Whats the benefit?' I hear you say. Well, remember that all ramda's functions can be called curried too? Likewise with over
! Lets see what happens:
const addOneYear8: AddOneYear = studentId => studentList =>
over(
lensPath([studentId, 'age']),
add(1)
)(studentList);
Still works but doesn't look nicer... But we do something weird here... It's now as if we implemented our addOne
function like this:
const addOne = b => add(1)(b);
In this example you clearly see that you don't need to mention b
at all. You can just implement addOne
by add(1)
.
Likewise, in the addOneYear8
function, this studentList
is now redundant and just makes our code extra noisy. So lets get rid of it!
const addOneYear9: AddOneYear = studentId =>
over(lensPath([studentId, 'age']), add(1));
Wow, see what happened here? Now the function has just turned into a one-liner! Lets try to read it's implementation:
"addOneYear9: over given students age, add 1."
Oh my! That is declaring exactly what it does and should do!
Compare results
Lets get back where we came from:
const addOneYear: AddOneYear = (studentId, studentList) => {
studentList[studentId].age++;
return studentList;
}
This implementation was short and simple. Not too hard to read. But it was wrong in mutating it's input.
At first we thought it would get more ugly and noisy to do it correctly treating the input as immutable, and you might even give up because of that. But then we discovered lenses and look what it has become:
const addOneYear9: AddOneYear = studentId =>
over(lensPath([studentId, 'age']), add(1));
It's now a one-liner! It's working correct in not mutating the input, not even mentioning it, and it's implementation is declaring exactly what it does. In a way it is even simpler than the first simple (but incorrect) version.
Ending
I'll end with a quote from John A. de Goes:
Don't study "advanced" programming techniques so you can write more "advanced" code.
Study them so you can write simpler code.
Posted on February 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.