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 }
Updating an array is similarly simple:
const users = [...]
const newUsers = users.concat([newPerson]) // or [...users, newPerson]
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'
}
}
Or updating an object within an array:
const users = [...]
const newUsers = [
{
...users[0],
address: {
...users[0].address,
index: 'NW1 8NZ'
},
...users.slice(1)
]
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
}
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
andA
? I couldn’t find, however, I can speculate thatS
stands for "Source" andA
stands for "Focus". The letterF
is often reserved for representing higher-order types, soA
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)
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"}]
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)
}
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"}]
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.
Here's how the getter is implemented:
const getter = R.pipe(
R.view(lens1),
R.view(lens2)
)
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)
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)
There is a difference in the lens composition approach described with
andThen
and the one withR.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'])
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"
}
]
*/
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')
)
And then update the array using the lens which focuses on specific person:
R.over(firstLens, updatePerson, persons)
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!
Top comments (0)