Skip to content

🔭 Lenses, Prisms and Traversals in JavaScript!

License

Notifications You must be signed in to change notification settings

kutyel/optics.js

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

67 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

optics.js

Last version bundlephobia Build Status Coverage Status Dependency status NPM Status

Lenses, Prisms and Traversals in JavaScript!


optics.js

Inspired by Haskell's optics package

Get it!

$ npm install optics.js --save

Meet it!

yt

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.

intro

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.

Use it!

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.

Amount of values

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 and toArray 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

Builders

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 whole hierarchy

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.

How it is made!

If you want to know more about the implementation, you can check this talk by myself at Lambda World.

lw

Know them all!

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'))

The notFound value

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

Combinators

optic : [Optic a b, Optic b c, ..., Optic y z] -> Optic a z

Creates a combined optic by applying each one on the result of the previous one. This is the most common way to combine optics.

collect : { k: Getter s a | PartialGetter s a | Fold s a } -> Getter s { k: v }

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)

transform : (s -> a) -> Getter s a

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'

sequence : [Fold s a] -> Fold s a

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' ]

firstOf : [Optic s a] -> Optic s a

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'

Lenses (view, set)

alter : k -> Lens (Object | notFound) (a | notFound)

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' }) // { }

mustBePresent : k -> Lens { k: a, ... } a

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.

ix : number -> Lens Array a

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.

duo : [v, (v) => nothing] -> Lens s v

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.

Optionals (preview, set)

maybe : (string | number) -> Optional (Object | Array) a

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' }

never : Optional s a

This optional never matches: viewing 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.

Prisms (preview, set, review)

where : { ...obj } -> Prism { ...obj, ...rest } { ...obj }

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' }

Traversals (toArray, set)

values : Traversal collection a

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]

entries : Traversal Object [k, v]

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

Getters (view)

always : a -> Getter s a

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'

Isos (view, set, review)

single : k -> Iso { k: a } a

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 Isos give you the review, you can use single to build objects too:

review(single('name'), 'Flavio') // { name: 'Flavio' }

License

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