DEV Community

Cover image for JavaScript Lenses: A Guide to Efficiently Modifying Immutable Data
Vladyslav Hutov
Vladyslav Hutov

Posted on

JavaScript Lenses: A Guide to Efficiently Modifying Immutable Data

The software industry has experienced a paradigm shift towards functional programming in recent years.

A paradigm shift in programming refers to a change in the way we approach writing code. Contrary to the common belief, a paradigm doesn’t bring new features, it comes with a set of limitations. These limitations, when embraced, can bring benefits of writing better software.

Here is what I mean:

  • Assembly language is a low-level language with no limitations, allowing for unlimited access to memory locations and the ability to manipulate bytes, pointers, and code flows.
  • Structural programming introduced the concept of code blocks, with limitations on how we treat iterations and subroutines.
  • Object-Oriented Programming (OOP) added further limitations by controlling how internal memory of objects can be accessed through encapsulation and abstraction.
  • Functional programming, limits us to the use of pure functions and immutable structures, leading to greater clarity and understanding in the code. This is particularly useful in parallel and concurrent programming, as pure functions and immutable objects minimize the chance of unintended modifications.

Working with immutable structures in functional programming can present challenges, particularly in JavaScript which lacks intrinsic tools for immutability. This requires developers to take responsibility for ensuring immutability. Simple updates, such as changing a value through destructuring, are straightforward:

const person = {
  name: 'John',
  age: 25
}

const newPerson = { ...person, age: 26 }
Enter fullscreen mode Exit fullscreen mode

Updating an array is similarly simple:

const users = [...]

const newUsers = users.concat([newPerson]) // or [...users, newPerson]
Enter fullscreen mode Exit fullscreen mode

However, as the complexity increases with nested structures, it becomes more difficult to create a new copy with updated data. For example, updating a nested object like person.address:

const person = {
  name: 'John',
  address: {
    street: '1 Water Ln',
    city: 'London'
    index: 'NW1 8NX'
  }
}

const newPerson = {
  ...person,
  address: {
    ...person.address,
    index: 'NW1 8NZ'
 }
}
Enter fullscreen mode Exit fullscreen mode

Or updating an object within an array:

const users = [...]

const newUsers = [
  {
    ...users[0],
    address: {
       ...users[0].address,
       index: 'NW1 8NZ'
  },
  ...users.slice(1)
]
Enter fullscreen mode Exit fullscreen mode

The more nested the structures, the greater the challenge in making updates without mutating the original data.

JavaScript doesn’t support an easy way to modify immutable data. - maybe YOU thinking

What is the solution then? Should we discard the concepts of pure functions and immutable structures? Should we avoid nesting objects and arrays? Is it necessary to switch to a different programming language?

The answer to each of these is no. Today, I'd like to introduce a concept that can address this issue. This concept emerged in 2010 with the extension library Lens for the Haskell programming language.

Don't be intimidated by Haskell, you'll realise the practicality of the Lens concept in a moment - me

A Lens allows you to focus on a specific part of an immutable data structure, serving as a functional equivalent of a getter and setter. It can be described as a generic class with two functions: a getter and a setter.

class Lens<S, A> {
  getter: (S) => A
  setter: (A, S) => S
}
Enter fullscreen mode Exit fullscreen mode

Where S represents an immutable object and A is the focused value within that object. The getter function defines how to view the value, and the setter is a pure function for updating the object and creating a new instance of S.

Why S and A? I couldn’t find, however, I can speculate that S stands for "Source" and A stands for "Focus". The letter F is often reserved for representing higher-order types, so A was chosen as a common placeholder for simple types.

The implementation of lenses in JavaScript can be done using libraries like Ramda, which provides functions for constructing lenses on objects and arrays. Ramda's R.lens function creates a lens by providing the getter and setter functions, while R.lensProp and R.lensIndex functions offer shortcuts for commonly used getters and setters.

const nameLens = R.lens(/*getter*/R.prop('name'), /*setter*/R.assoc('name'))
// use of shortcuts
const nameLens = R.lensProp('name')
const firstLens = R.lensIndex(0)
Enter fullscreen mode Exit fullscreen mode

So now that we have lenses, what can we do with them? Lenses provide us with the ability to retrieve (using R.view), modify (using R.set), or update (using R.over) properties within a structure.

const persons = [
  {name: 'John'},
  {name: 'Steve'}
]

const updateName = person =>
  R.over(nameLens, name => name + ' Smith', person)

R.over(firstLens, updateName, persons) // [{"name": "John Smith"}, {"name": "Steve"}]

persons // [{"name": "John"}, {"name": "Steve"}]
Enter fullscreen mode Exit fullscreen mode

In the example above, we utilised an intermediate function to update an array's value. But did you know that lenses can be composed? Let's write a helper function to combine two lenses, allowing us to focus on a smaller part of a structure with a single lens.

function andThen<S, S1, A>(lens1: Lens[S, S1], lens2: Lens[S1, A]): Lens[S, A] {

  const getter = R.pipe(
    R.view(lens1),
    R.view(lens2)
  )

  const setter = (a, s) => 
    R.over(lens1, R.set(lens2, a), s)

  return R.lens(getter, setter)
}
Enter fullscreen mode Exit fullscreen mode

This is how we can use the function:

const nameLens = R.lensProp('name')
const firstLens = R.lensIndex(0)

const nameInFirstLens = andThen(firstLens, nameLens)

const persons = [
  {name: 'John'},
  {name: 'Steve'}
]

R.over(nameInFirstLens, name => name + ' Smith', persons) // [{"name": "John Smith"}, {"name": "Steve"}]
persons // [{"name": "John"}, {"name": "Steve"}]
Enter fullscreen mode Exit fullscreen mode

Let's break down how the andThen composition works.

We'll start with the getter aspect. We have a top-level lens lens1 that focuses on the internal structure S1 of S and another lens lens2 that focuses on the internal value A of S1.

Lens composition

Here's how the getter is implemented:

const getter = R.pipe(
   R.view(lens1),
   R.view(lens2)
 )
Enter fullscreen mode Exit fullscreen mode

R.pipe runs the functions in sequence, with the output from one function serving as input to the next. In this case, the getter first uses lens1 to view the value, which gives the internal structure, then uses lens2 to view the value within that structure.

The setter implementation is more complex:

const setter = (a, s) => 
    R.over(lens1, R.set(lens2, a), s)
Enter fullscreen mode Exit fullscreen mode

The setter is a function that takes two inputs (A, S) and returns S. Let's start by examining R.set(lens2, a), which creates an update function S1 => S1 that updates the value focused by lens2 to a.

The R.over function then updates the value S1 in S using lens1 to focus on it and the update function created by R.set. The signature of R.over is <S, S1>(Lens[S, S1>, S1 => S1, S) => S.

You can simplify lens composition with R.compose from Ramda:

const nameLens = R.lensProp('name')
const firstLens = R.lensIndex(0)

const nameInFirstLens = R.compose(firstLens, nameLens)
Enter fullscreen mode Exit fullscreen mode

There is a difference in the lens composition approach described with andThen and the one with R.compose, but the end result is the same and there's no need to worry about it.

For object-specific composition you can use R.lensPath:

const addressLens = R.lensProp('address')
const zipLens = R.lensProp('zip')

const zipInAddressLens = R.compose(addressLens, zipLens)
const zipInAddressLens2 = R.lensPath(['address', 'zip'])
Enter fullscreen mode Exit fullscreen mode

Multiple updates can be composed as well:

const nameLens = R.lensProp('name')
const ageLens = R.lensProp('age')
const zipAddressLens = R.lensPath(['address', 'zip'])

const firstLens = R.lensIndex(0)

const nameInFirst = R.compose(firstLens, nameLens)
const ageInFirst = R.compose(firstLens, ageLens)
const zipInFirst = R.compose(firstLens, zipAddressLens)

const persons = [
  {name: 'John', age: 25, address: { zip: 'NW1 8NZ' } },
  {name: 'Steve', age: 35, address: { zip: 'NW1 8NZ' } }
]

R.compose(
  R.set(ageInFirst, 30),
  R.over(nameInFirst, name => name + ' Smith'),
  R.set(zipInFirst, 'NW1 8NX')
)(persons)
/*
  [
    {
        address: {
            zip: "NW1 8NX"
        },
        age: 30,
        name: "John Smith"
    },
    {
        address: {
            zip: "NW1 8NZ"
        },
        age: 35,
        name: "Steve"
    }
]
*/
Enter fullscreen mode Exit fullscreen mode

However, this approach duplicates the array three times to update one person. To reduce this, you can create a composed update function for a single person:

const updatePerson = R.compose(
  R.set(ageLens, 30),
  R.over(nameLens, name => name + ' Smith'),
  R.set(zipAddressLens, 'NW1 8NX')
)
Enter fullscreen mode Exit fullscreen mode

And then update the array using the lens which focuses on specific person:

R.over(firstLens, updatePerson, persons)
Enter fullscreen mode Exit fullscreen mode

This produces the same result but avoids creating intermediate arrays that are later discarded.

Now you have a tool to immutably update objects in JS of any composition structure without boilerplate.

If you have questions, leave your comments below!

Follow for more!

Oldest comments (0)