Lenses, Prisms and Traversals in JavaScript!
Inspired by Haskell's optics
package
$ npm install optics.js --save
import { where, maybe, optic, values } from 'optics.js'
const people = [
{
name: { first: 'Alejandro', last: 'Serrano' },
birthmonth: 'april',
age: 32,
},
{
name: { first: 'Flavio', last: 'Corpa' },
birthmonth: 'april',
age: 29,
},
{ name: { first: 'Laura' }, birthmonth: 'august', age: 27 },
]
const firstNameTraversal = optic(values, 'name', 'first').toArray(people)
const lastNameOptional = optic(values, 'name', maybe('last')).toArray(people)
const o = optic(values, where({ birthmonth: 'april' }), 'age')
const ageTraversal = o.toArray(people)
const agePlus1Traversal = o.over(x => x + 1, people)
Optics provide a language for data access and manipulation in a concise and compositional way. It excels when you want to code in an immutable way.
There are very few moving parts in optics.js
, the power comes from the ability to create long combinations or paths by composing small primitive optics. Let's look at an example:
import { optic, maybe, view, over } from 'optics.js'
const wholeMilk = optic('milk', maybe('whole'))
In most cases, the optic
function will be your starting point. It takes any amount of arguments describing primitive optics, and fuses them together. In this case, we are creating an optic which accesses the milk
key, and then accesses the whole
key if available (notice that the name is wrapped with maybe
).
Intuitively, optics simply point to one (or more) positions within your data. You can then operate at that position in a particular piece of data. In the following example we obtain the value within the shopping list, and then increment it.
const shoppingList = { pie: 3, milk: { whole: 6, skimmed: 3 } }
view(wholeMilk, shoppingList) // > 6
over(wholeMilk, x => x + 1, shoppingList)
// > { pie: 3, milk: { whole: 7, skimmed: 3 } }
As mentioned above, the result of over
is a fresh object, so immutability is guaranteed.
As discussed while meeting the library, optics.js
is based around a few primitive optics, which are composed using the optic
function. Different kinds of optics support different operations; that is one of the key ideas. You can apply those operations in two ways, depending on your preferred coding style:
operation(optic, ...other args, value)
optic.operation(...other args, value)
In any case, the optic always goes first, and the value to which the operation should be applied goes last.
An optic can target zero, one, or an unrestricted amount of positions within your data. This allows us, for example, to provide an optic which targets every value within an array (unrestricted amount) or targets an optional value (zero or one). The operations are called differently depending on this fact:
view
targets exactly one value, like a property in an object which we are guaranteed to have.preview
targets zero or one values, which essentially amounts to an optional value, like an index in an array which may go out of bounds.reduce
andtoArray
target an unrestricted amount, like the aforementioned array or the values within an object.
It is always safe to treat an optic in a less restricted way. For example, if your optic targets exactly one value, you can also use preview
or toArray
over it.
In any of the three cases you may be able to modify the values targeted by the optic. You can do so in two ways:
set
takes a single value, and replaces every position pointed by the data with it.over
takes a function which is applied at each position targeted by the optic.
Since we have three "levels of amounts" and two possibilities about setting (we are able or not), we get six different kinds of optics, plus an additional one for setting without access. Those receive different names, as shown in the following table (names in parentheses are those used by other similar libraries.)
set ? |
Exactly 1 | 0 or 1 | Unrestricted | No access |
---|---|---|---|---|
Yes | Lens |
Optional (AffineTraversal ) |
Traversal  |
Setter |
 No | Getter |
PartialGetter (AffineFold )Â |
Fold  |
does not exist |
The previous six kinds of optics can only access or modify values. There is one additional capability an optic may have: being able to create values. Take for example the Optional
which accesses an object with a single key k
. If we give this optic a value, it can create a new object with that single key:
import { review, single } from 'optics.js'
review(single('say'), 'hi!') // > { say: 'hi!' }
This adds yet another axis to our previous table, depending on whether when accessing you are guaranteed to have a value or not.
Exactly 1 | 0 or 1 | Unrestricted | No access |
---|---|---|---|
Iso |
Prism |
does not exist | Reviewer |
The different kinds of optics can be arranged into a hierarchy. Going up means weakening the restrictions, either by set of operations or by amount of elements.
The image has been produced from the diagram in the
optics
package.
If you want to know more about the implementation, you can check this talk by myself at Lambda World.
optics.js
ships with a bunch of predefined optics. Bear in mind that the alter
and ix
lenses are the default when you use combinators, so:
optic(maybe('friends'), 0, 'name')
is equivalent to (the longer):
optic(maybe('friends'), ix(0), alter('name'))
Whenever an optic needs to indicate that there is no value to return, the special value notFound
is used. This value is different from null
, undefined
, and any other except itself. To check whether an optic returns no values, you can use one of the following versions:
if (isNotFound(preview(maybe('age'), person))) ...
if (maybe('age').preview(person) === notFound) ...
Note that notFound
is not falsy, so the following JavaScript idioms are not available:
if (!preview(maybe('age'), person)) ...
maybe('age').preview(person) || defaultAge
Creates a combined optic by applying each one on the result of the previous one. This is the most common way to combine optics.
Generates a new object whose keys are based on the given optics. Depending on the type of optic, a single value, optional value, or array is collected.
collect({ edad: optic('age') }).view({ name: 'Alex', age: 32 }) // { edad: 32 }
In addition, if every optic is a lens (or can be turned into one), then the result is also a lens. This makes over
able to change part of a data structure depending on the value of other elements.
collect({ month: optic('birthmonth'), age: optic('age') })
.over(x => { ...x, age: x.month === 'april' ? x.age + 1 : x.age }, person)
Applies a transformation to the values targetted up to then. Since the transformation may not be reversible, after composing with transform
you lose the ability to set or modify the value.
transform(x => x + 1).view(2) // 3
This can be very useful in combination with collect
.
optic(
collect({ first: optic('firstName'), last: optic('lastName') }),
transform(x => x.first + ' ' + x.last),
).view({ first: 'Alex', last: 'Smith' }) // 'Alex Smith'
Joins the result of several optics into a single one. In other words, targets all values from each of the given optics.
sequence('age', 'name').toArray({ name: 'Alex', age: 32 }) // [ 32, 'Alex' ]
Tries each of the optics until one matches, that is, returns something different from notFound
(when talking about optionals or partial getters) and []
(when talking about traversals and folds).
view(firstOf('firstName', 'name'), { name: 'Alex', age: 32 }) // 'Alex'
In combination with always
it can be used to provide a default value when an optic targets no elements.
view(firstOf('name', always('Pepe')), { name: 'Alex', age: 32 }) // 'Alex'
view(firstOf('name', always('Pepe')), { }}) // 'Pepe'
Given a key
, its view
operation returns:
- the value at that key if present in the object,
- the special value
notFound
if not.
The set
/over
operation can be used to modify, create, and remove keys in an object.
set(optic('name'), 'Alex', { name: 'Flavio' }) // { name: 'Alex' }
set(optic('name'), 'Alex', {}) // { name: 'Alex' }
set(optic('name'), notFound, { name: 'Flavio' }) // {Â }
Given a key, both view
and set
/over
operate on that key in an object. However, the key must already be present, otherwise undefined
is returned.
Given an index, both view
and set
/over
operate on that index in an array. This lens cannot be used to grow or shrink the array, you can only access or modify positions which are already available.
Wraps a two-element pair [current value, setter for the value]
as a lens. This is useful in combination with React's useState
. Note that in this case the last argument of view
or set
, the element you are changing, is irrelevant.
In a similar fashion to alter
, the view
operation returns:
- the value at the key, or at the given index, if present in the object or array,
- the special value
notFound
if not.
However, maybe
does not create or remove keys from an object. The most common use is to modify only values which are already there.
over(maybe('age'), x => x + 1, { name: 'Alex', age: 32 })
// { name: 'Alex', age: 33 }
over(maybe('age'), x => x + 1, { name: 'Flavio' })
// { name: 'Flavio' }
This optional never matches: view
ing through it always returns notFound
, using it to set makes no changes to the given value. It can be useful when combined in sequence
, as it adds no additional values.
This prism targets only objects which contain a given "subobject". This might be seen more clearly with a few examples:
preview(optic(where({ id: 1 }), 'name'), { id: 1, name: 'Alex' }) // 'Alex'
preview(optic(where({ id: 1 }), 'name'), { id: 2, name: 'Alex' }) // notFound
This prism is quite useful when dealing with discriminating unions, like those usually found in Redux actions:
optic(where({ type: 'ADD_ITEM' }), ...)
Since it is a prism, where
may also be used for constructing objects. In that case, it ensures that the subobject is part of the created:
where({ type: 'ADD_ITEM' }).review({ item: 'Hello' })
// { type: 'ADD_ITEM', item: 'Hello' }
When combined with traversals like values
, where
can be used to filter out values.
// return only people who were born in April
toArray(optic(values, where({ birthmonth: 'april' })), people)
Note that when matching the optic itself returns the whole object again:
preview(where({ id: 1 })), {Â id: 1, name: 'Alex' }) // {Â id: 1, name: 'Alex' }
Targets every position in an Array and most of Immutable.js collections. You can also use it to access the values in a Set or Map, but should not be used to modify them with set
/over
.
over(values, x => x + 1, [1, 2, 3]) // [2, 3, 4]
Targets every key-value pair in an object. As with the identically-named entries
method in Object
, those pairs are returned as two-element arrays:
toArray(entries, { name: 'Alex', age: 32 })
// [ ['name', 'Alex'], ['age', 32] ]
When using set
/over
with entries
, you must always return the same key that was given to you, or BadThingWillHappen
.
over(entries, ([k, v]) => [k, v + 1], numbers) // right
over(entries, ([k, v]) => v + 1, numbers) // throws TypeError
Always return the given value. As described above, it can be used in combination with firstOf
to provide a default value for a possibly-missing field.
view(always('zoo'), 3) // 'zoo'
This optic matches may only focus on values with a single key. Note that this is not an Optional
or Prism
, so trying to use it on a value with a different shape raises an error instead of returning notFound
.
Since Iso
s give you the review
, you can use single
to build objects too:
review(single('name'), 'Flavio') // { name: 'Flavio' }
optics.js © Flavio Corpa, released under the MIT License.
Authored and maintained by Flavio Corpa and Alejandro Serrano with help from contributors.
GitHub Flavio Corpa · Twitter @FlavioCorpa · GitHub Alejandro Serrano · Twitter @trupill