DEV Community

loading...

Mutating the immutable

Johan
・8 min read

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
    }
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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????
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Does it work?

const changedStudents = addOneYear3('abc', students);

console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Yay!!
Enter fullscreen mode Exit fullscreen mode

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
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

Ok... Does it still work?

const changedStudents = addOneYear4('abc', students);

console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Yes
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Run it...

const changedStudents = addOneYear5('abc', students);

console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Nice!
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

Run it...

const changedStudents = addOneYear6('abc', students);

console.log(changedStudents.abc.age); // 13. OK!
console.log(students.abc.age); // 12. Nice!
Enter fullscreen mode Exit fullscreen mode

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
    );
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
    );
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Now it becomes:

type AddOneYear = (studentId: string) => (studentList: Students) => Students;
Enter fullscreen mode Exit fullscreen mode

Allmost the same, right? Ok, so how to implement it?

const addOneYear8: AddOneYear = studentId => studentList =>
    over(
        lensPath([studentId, 'age']),
        add(1),
        studentList
    );
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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.


Discussion (0)