Published on 20 January 2023
[2, 'two']
is a product type combining a number and a string. The most common product type in JS is an object, and it will be the focus of the rest of this article.const user = {
name: 'John Doe',
age: 29,
address: {
street: 'New Street',
city: 'Birmingham',
},
}
user
is a value of a product type of a string, a number, and another object which itself is a product type of two strings. We typically ignore the key types since the object type itself is isomorphic to a tuple containing those values, so ultimately the keys don’t really matter.const userCity = user.address.city // == 'Birmingham'
.
operator can throw an error if the value on the left of it is not an object. This has been a serious concern for a long time in JS that required meticulous null-checking, until we got the “wtf operator” in ?.
.user.address.city
since that would mutate the object. Instead we have to resort to spreading the remaining values on every intermediate level, which is not only error-prone and verbose, but also very tightly couples the logic of retrieving nested fields with the data itself.const userInLondon = {
...user,
address: {
...user.address,
city: 'London',
},
}
view
and set
. Alternatively, you might find it helpful to think in terms of “unwrapping” and “wrapping” values. (Viewing is like “unwrapping” a smaller value from a larger context, setting is like “wrapping” a smaller value into a larger context.)view
is a function that takes a value of a product type and returns the focused value. A view
function for the name
field of our user (or indeed any product type containing a value under that key) would look like:const view = user => user.name
set
is a function that takes a whole value, a new focused value, and returns a new whole value. Again, for the name
field it would be:const set = (user, newName) => ({ ...user, name: newName })
name
field. It would make sense to create a factory which would let us use them for any field.const createLens = field => ({
view: whole => whole[field],
set: (whole, part) => ({ ...whole, [field]: part }),
})
view
and set
functions that take a lens and apply its functions. We’ll also create a function that applies a given function to the focused value. Conventionally this kind of function is called over
.const view = (lens, whole) => lens.view(whole)
const set = (lens, whole, part) => lens.set(whole, part)
const over = (lens, whole, fn) => set(lens, whole, fn(view(lens, whole)))
const upcase = str => str.toUpperCase()
over(createLens('name'), user, upcase) // == { name: "JOHN DOE", ... }
address.city
field:const address = createLens('address') const city = createLens('city') view(city, view(address, user)) // == 'Birmingham'
set(address, user, set(city, view(address, user), 'London')) // { address: { city: 'London', ... }, ... }
const composeTwo = (outer, inner) => ({
view: whole => view(inner, view(outer, whole)),
set: (whole, part) => set(outer, whole, set(inner, view(outer, whole), part)),
})
const compose = (...lenses) => lenses.reduce(composeTwo)
const addressCity = compose(createLens('address'), createLens('city')) view(addressCity, user) // == 'Birmingham' set(addressCity, user, 'London') // { address: { city: 'London', ... }, ... }
createLens
factory that we wrote up earlier is only good for objects and their keys (hence you will most often find it actually called lensProp
). We can create lenses for other product types. For instance, let’s create a lens that returns the first element of an array. We will assume that the element always exists, though in a real application it would be best to ensure that by some other means.const first = {
view: ([f]) => f,
set: ([_, ...rest], f) => ([f, ...rest])
}
view(first, [1, 2, 3]) // == 1
first
is a lens like any other, we can compose it with other lenses.const user = {
name: 'John Doe',
age: 29,
address: [
{ street: 'New Street', city: 'Birmingham' },
{ street: 'Abbey Road', city: 'London' },
]
}
const addressFirstCity = compose(address, first, city)
view(addressFirstCity, user) // == 'Birmingham'
const sign = {
view: n => n >= 0
set: (n, sign) => sign ? Math.abs(n) : -Math.abs(n)
}
view(sign, 1) // == true
view(sign, -3) // == false
set(sign, 3, true) // == 3
set(sign, -5, true) // == 5
set(sign, 4, false) // == -4