14.17.0 • Published 5 years ago

partial.lenses v14.17.0

Weekly downloads
1,952
License
MIT
Repository
github
Last release
5 years ago

Partial Lenses · Gitter GitHub stars npm

Lenses are basically an abstraction for simultaneously specifying operations to update and query immutable data structures. Lenses are highly composable and can be efficient. This library provides a rich collection of partial isomorphisms, lenses, and traversals, collectively known as optics, for manipulating JSON and users can write new optics for manipulating non-JSON objects, such as Immutable.js collections. A partial lens can view optional data, insert new data, update existing data and remove existing data and can, for example, provide defaults and maintain required data structure parts. Try Lenses!

npm version Bower version Build Status Code Coverage npm.io npm.io

Contents

Tutorial

Let's look at an example that is based on an actual early use case that lead to the development of this library. What we have is an external HTTP API that both produces and consumes JSON objects that include, among many other properties, a titles property:

const sampleTitles = {
  titles: [
    {language: 'en', text: 'Title'},
    {language: 'sv', text: 'Rubrik'}
  ]
}

We ultimately want to present the user with a rich enough editor, with features such as undo-redo and validation, for manipulating the content represented by those JSON objects. The titles property is really just one tiny part of the data model, but, in this tutorial, we only look at it, because it is sufficient for introducing most of the basic ideas.

So, what we'd like to have is a way to access the text of titles in a given language. Given a language, we want to be able to

  • get the corresponding text,
  • update the corresponding text,
  • insert a new text and the immediately surrounding object in a new language, and
  • remove an existing text and the immediately surrounding object.

Furthermore, when updating, inserting, and removing texts, we'd like the operations to treat the JSON as immutable and create new JSON objects with the changes rather than mutate existing JSON objects, because this makes it trivial to support features such as undo-redo and can also help to avoid bugs associated with mutable state.

Operations like these are what lenses are good at. Lenses can be seen as a simple embedded DSL for specifying data manipulation and querying functions. Lenses allow you to focus on an element in a data structure by specifying a path from the root of the data structure to the desired element. Given a lens, one can then perform operations, like get and set, on the element that the lens focuses on.

Getting started

Let's first import the libraries

import * as L from 'partial.lenses'
import * as R from 'ramda'

and ▶ play just a bit with lenses.

Note that links with the ▶ play symbol, take you to an interactive version of this page where almost all of the code snippets are editable and evaluated in the browser. There is also a separate playground page that allows you to quickly try out lenses.

As mentioned earlier, with lenses we can specify a path to focus on an element. To specify such a path we use primitive lenses like L.prop(propName), to access a named property of an object, and L.index(elemIndex), to access an element at a given index in an array, and compose the path using L.compose(...lenses).

So, to just get at the titles array of the sampleTitles we can use the lens L.prop('titles'):

L.get(L.prop('titles'), sampleTitles)
// [{ language: 'en', text: 'Title' },
//  { language: 'sv', text: 'Rubrik' }]

To focus on the first element of the titles array, we compose with the L.index(0) lens:

L.get(L.compose(L.prop('titles'), L.index(0)), sampleTitles)
// { language: 'en', text: 'Title' }

Then, to focus on the text, we compose with L.prop('text'):

L.get(L.compose(L.prop('titles'), L.index(0), L.prop('text')), sampleTitles)
// 'Title'

We can then use the same composed lens to also set the text:

L.set(
  L.compose(L.prop('titles'), L.index(0), L.prop('text')),
  'New title',
  sampleTitles
)
// { titles: [{ language: 'en', text: 'New title' },
//            { language: 'sv', text: 'Rubrik' }] }

In practise, specifying ad hoc lenses like this is not very useful. We'd like to access a text in a given language, so we want a lens parameterized by a given language. To create a parameterized lens, we can write a function that returns a lens. Such a lens should then find the title in the desired language.

Furthermore, while a simple path lens like above allows one to get and set an existing text, it doesn't know enough about the data structure to be able to properly insert new and remove existing texts. So, we will also need to specify such details along with the path to focus on.

A partial lens to access title texts

Let's then just compose a parameterized lens for accessing the text of titles:

const textIn = language => L.compose(
  L.prop('titles'),
  L.normalize(R.sortBy(L.get('language'))),
  L.find(R.whereEq({language})),
  L.valueOr({language, text: ''}),
  L.removable('text'),
  L.prop('text')
)

Take a moment to read through the above definition line by line. Each part either specifies a step in the path to select the desired element or a way in which the data structure must be treated at that point. The L.prop(...) parts are already familiar. The other parts we will mention below.

Querying data

Thanks to the parameterized search part, L.find(R.whereEq({language})), of the lens composition, we can use it to query titles:

L.get(textIn('sv'), sampleTitles)
// 'Rubrik'

The L.find lens is given a predicate that it then uses to find an element from an array to focus on. In this case the predicate is specified with the help of Ramda's R.whereEq function that creates an equality predicate from a given template object.

Missing data can be expected

Partial lenses can generally deal with missing data. In this case, when L.find doesn't find an element, it instead works like a lens to append a new element into an array.

So, if we use the partial lens to query a title that does not exist, we get the default:

L.get(textIn('fi'), sampleTitles)
// ''

We get this value, rather than undefined, thanks to the L.valueOr({language, text: ''}) part of our lens composition, which ensures that we get the specified value rather than null or undefined. We get the default even if we query from undefined:

L.get(textIn('fi'), undefined)
// ''

With partial lenses, undefined is the equivalent of non-existent.

Updating data

As with ordinary lenses, we can use the same lens to update titles:

L.set(textIn('en'), 'The title', sampleTitles)
// { titles: [ { language: 'en', text: 'The title' },
//             { language: 'sv', text: 'Rubrik' } ] }

Inserting data

The same partial lens also allows us to insert new titles:

L.set(textIn('fi'), 'Otsikko', sampleTitles)
// { titles: [ { language: 'en', text: 'Title' },
//             { language: 'fi', text: 'Otsikko' },
//             { language: 'sv', text: 'Rubrik' } ] }

There are a couple of things here that require attention.

The reason that the newly inserted object not only has the text property, but also the language property is due to the L.valueOr({language, text: ''}) part that we used to provide a default.

Also note the position into which the new title was inserted. The array of titles is kept sorted thanks to the L.normalize(R.sortBy(L.get('language'))) part of our lens. The L.normalize lens transforms the data when either read or written with the given function. In this case we used Ramda's R.sortBy to specify that we want the titles to be kept sorted by language.

Removing data

Finally, we can use the same partial lens to remove titles:

L.set(textIn('sv'), undefined, sampleTitles)
// { titles: [ { language: 'en', text: 'Title' } ] }

Note that a single title text is actually a part of an object. The key to having the whole object vanish, rather than just the text property, is the L.removable('text') part of our lens composition. It makes it so that when the text property is set to undefined, the result will be undefined rather than merely an object without the text property.

If we remove all of the titles, we get an empty array:

L.set(L.seq(textIn('sv'), textIn('en')), undefined, sampleTitles)
// { titles: [] }

Above we use L.seq to run the L.set operation over both of the focused titles.

Exercises

Take out one (or more) L.normalize(...), L.valueOr(...) or L.removable(...) part(s) from the lens composition and try to predict what happens when you rerun the examples with the modified lens composition. Verify your reasoning by actually rerunning the examples.

Shorthands

For clarity, the previous code snippets avoided some of the shorthands that this library supports. In particular,

Systematic decomposition

It is also typical to compose lenses out of short paths following the schema of the JSON data being manipulated. Recall the lens from the start of the example:

L.compose(
  L.prop('titles'),
  L.normalize(R.sortBy(L.get('language'))),
  L.find(R.whereEq({language})),
  L.valueOr({language, text: ''}),
  L.removable('text'),
  L.prop('text')
)

Following the structure or schema of the JSON, we could break this into three separate lenses:

  • a lens for accessing the titles of a model object,
  • a parameterized lens for querying a title object from titles, and
  • a lens for accessing the text of a title object.

Furthermore, we could organize the lenses to reflect the structure of the JSON model:

const Title = {
  text: [L.removable('text'), 'text']
}

const Titles = {
  titleIn: language => [
    L.find(R.whereEq({language})),
    L.valueOr({language, text: ''})
  ]
}

const Model = {
  titles: ['titles', L.normalize(R.sortBy(L.get('language')))],
  textIn: language => [Model.titles, Titles.titleIn(language), Title.text]
}

We can now say:

L.get(Model.textIn('sv'), sampleTitles)
// 'Rubrik'

This style of organizing lenses is overkill for our toy example. In a more realistic case the sampleTitles object would contain many more properties. Also, rather than composing a lens, like Model.textIn above, to access a leaf property from the root of our object, we might actually compose lenses incrementally as we inspect the model structure.

Manipulating multiple items

So far we have used a lens to manipulate individual items. This library also supports traversals that compose with lenses and can target multiple items. Continuing on the tutorial example, let's define a traversal that targets all the texts:

const texts = [Model.titles, L.elems, Title.text]

What makes the above a traversal is the L.elems part. The result of composing a traversal with a lens is a traversal. The other parts of the above composition should already be familiar from previous examples. Note how we were able to use the previously defined Model.titles and Title.text lenses.

Now, we can use the above traversal to collect all the texts:

L.collect(texts, sampleTitles)
// [ 'Title', 'Rubrik' ]

More generally, we can map and fold over texts. For example, we could use L.maximumBy to find a title with the maximum length:

L.maximumBy(R.length, texts, sampleTitles)
// 'Rubrik'

Of course, we can also modify texts. For example, we could uppercase all the titles:

L.modify(texts, R.toUpper, sampleTitles)
// { titles: [ { language: 'en', text: 'TITLE' },
//             { language: 'sv', text: 'RUBRIK' } ] }

We can also manipulate texts selectively. For example, we could remove all the texts that are longer than 5 characters:

L.remove([texts, L.when(t => t.length > 5)], sampleTitles)
// { titles: [ { language: 'en', text: 'Title' } ] }

Next steps

This concludes the tutorial. The reference documentation contains lots of tiny examples and a few more involved examples. The examples section describes a couple of lens compositions we've found practical as well as examples that may help to see possibilities beyond the immediately obvious. The wiki contains further examples and playground links. There is also a document that describes a simplified implementation of optics in a similar style as the implementation of this library. Last, but perhaps not least, there is also a page of Partial Lenses Exercises to solve.

The why of optics

Optics provide a way to decouple the operation to perform on an element or elements of a data structure from the details of selecting the element or elements and the details of maintaining the integrity of the data structure. In other words, a selection algorithm and data structure invariant maintenance can be expressed as a composition of optics and used with many different operations.

Consider how one might approach the tutorial problem without optics. One could, for example, write a collection of operations like getText, setText, addText, and remText:

const getEntry = R.curry(
  (language, data) => data.titles.find(R.whereEq({language}))
)
const hasText = R.pipe(getEntry, Boolean)
const getText = R.pipe(getEntry, R.defaultTo({}), R.prop('text'))
const mapProp = R.curry(
  (fn, prop, obj) => R.assoc(prop, fn(R.prop(prop, obj)), obj)
)
const mapText = R.curry(
  (language, fn, data) => mapProp(
    R.map(R.ifElse(R.whereEq({language}), mapProp(fn, 'text'), R.identity)),
    'titles',
    data
  )
)
const remText = R.curry(
  (language, data) => mapProp(
    R.filter(R.complement(R.whereEq({language}))),
    'titles'
  )
)
const addText = R.curry(
  (language, text, data) => mapProp(R.append({language, text}), 'titles', data)
)
const setText = R.curry(
  (language, text, data) => mapText(language, R.always(text), data)
)

You can definitely make the above operations both cleaner and more robust. For example, consider maintaining the ordering of texts and the handling of cases such as using addText when there already is a text in the specified language and setText when there isn't. With partial optics, however, you separate the selection and data structure invariant maintenance from the operations as illustrated in the tutorial and due to the separation of concerns that tends to give you a lot of robust functionality in a small amount of code.

Reference

The combinators provided by this library are available as named imports. Typically one just imports the library as:

import * as L from 'partial.lenses'

Stable subset

This library has historically been developed in a fairly aggressive manner so that features have been marked as obsolete and removed in subsequent major versions. This can be particularly burdensome for developers of libraries that depend on partial lenses. To help the development of such libraries, this section specifies a tiny subset of this library as stable. While it is possible that the stable subset is later extended, nothing in the stable subset will ever be changed in a backwards incompatible manner.

The following operations, with the below mentioned limitations, constitute the stable subset:

The main intention behind the stable subset is to enable a dependent library to make basic use of lenses created by client code using the dependent library.

In retrospect, the stable subset has existed since version 2.2.0.

Additional libraries

The main Partial Lenses library aims to provide robust general purpose combinators for dealing with plain JavaScript data. Combinators that are more experimental or specialized in purpose or would require additional dependencies aside from the Infestines library, which is mainly used for the currying helpers it provides, are not provided.

Currently the following additional Partial Lenses libraries exist:

Optics

The abstractions, traversals, lenses, and isomorphisms, provided by this library are collectively known as optics. Traversals can target any number of elements. Lenses are a restriction of traversals that target a single element. Isomorphisms are a restriction of lenses with an inverse.

In addition to basic bidirectional optics, this library also supports more arbitrary transforms using optics with sequencing and transform ops. Transforms allow operations, such as modifying a part of data structure multiple times or even in a loop, that are not possible with basic optics.

Some optics libraries provide many more abstractions, such as "optionals", "prisms" and "folds", to name a few, forming a DAG. Aside from being conceptually important, many of those abstractions are not only useful but required in a statically typed setting where data structures have precise constraints on their shapes, so to speak, and operations on data structures must respect those constraints at all times.

On the other hand, in a dynamically typed language like JavaScript, the shapes of run-time objects are naturally malleable. Nothing immediately breaks if a new object is created as a copy of another object by adding or removing a property, for example. We can exploit this to our advantage by considering all optics as partial and manage with a smaller amount of distinct classes of optics.

On partiality

By definition, a total function, or just a function, is defined for all possible inputs. A partial function, on the other hand, may not be defined for all inputs.

As an example, consider an operation to return the first element of an array. Such an operation cannot be total unless the input is restricted to arrays that have at least one element. One might think that the operation could be made total by returning a special value in case the input array is empty, but that is no longer the same operationthe special value is not the first element of the array.

Now, in partial lenses, the idea is that in case the input does not match the expectation of an optic, then the input is treated as being undefined, which is the equivalent of non-existent: reading through the optic gives undefined and writing through the optic replaces the focus with the written value. This makes the optics in this library partial and allows specific partial optics, such as the simple L.prop lens, to be used in a wider range of situations than corresponding total optics.

Making all optics partial has a number of consequences. For one thing, it can potentially hide bugs: an incorrectly specified optic treats the input as undefined and may seem to work without raising an error. We have not found this to be a major source of bugs in practice. However, partiality also has a number of benefits. In particular, it allows optics to seamlessly support both insertion and removal. It also allows to reduce the number of necessary abstractions and it tends to make compositions of optics more concise with fewer required parts, which both help to avoid bugs.

On indexing

Optics in this library support a simple unnested form of indexing. When focusing on an array element or an object property, the index of the array element or the key of the object property is passed as the index to user defined functions operating on that focus.

For example:

L.get(
  [L.find(R.equals('bar')), (value, index) => ({value, index})],
  ['foo', 'bar', 'baz']
)
// {value: 'bar', index: 1}
L.modify(L.values, (value, key) => ({key, value}), {x: 1, y: 2})
// {x: {key: 'x', value: 1}, y: {key: 'y', value: 2}}

Only optics directly operating on array elements and object properties produce indices. Most optics do not have an index of their own and they pass the index given by the preceding optic as their index. For example, L.when doesn't have an index by itself, but it passes through the index provided by the preceding optic:

L.collectAs(
  (value, index) => ({value, index}),
  [L.elems, L.when(x => x > 2)],
  [3, 1, 4, 1]
)
// [{value: 3, index: 0}, {value: 4, index: 2}]
L.collectAs(
  (value, key) => ({value, key}),
  [L.values, L.when(x => x > 2)],
  {x: 3, y: 1, z: 4, w: 1}
)
// [{value: 3, key: 'x'}, {value: 4, key: 'z'}]

When accessing a focus deep inside a data structure, the indices along the path to the focus are not collected into a path. However, it is possible to use index manipulating combinators to construct paths of indices and more. For example:

L.collectAs(
  (value, path) => [L.collect(L.flatten, path), value],
  L.lazy(rec => L.ifElse(R.is(Object), [L.joinIx(L.children), rec], [])),
  {a: {b: {c: 'abc'}}, x: [{y: [{z: 'xyz'}]}]}
)
// [ [ [ "a", "b", "c", ], "abc", ],
//   [ [ "x", 0, "y", 0, "z", ], "xyz", ] ]

The reason for not collecting paths by default is that doing so would be relatively expensive due to the additional allocations. The L.choose combinator can also be useful in cases where there is a need to access some index or context along the path to a focus.

On immutability

Starting with version 10.0.0, to strongly guide away from mutating data structures, optics call Object.freeze on any new objects they create when NODE_ENV is not production.

Why only non-production builds? Because Object.freeze can be quite expensive and the main benefit is in catching potential bugs early during development.

Also note that optics do not implicitly "deep freeze" data structures given to them or freeze data returned by user defined functions. Only objects newly created by optic functions themselves are frozen.

Starting with version 13.10.0, the possibility that optics do not unnecessarily clone input data structures is explicitly acknowledged. In case all elements of an array or object produced by an optic operation would be the same, as determined by Object.is, then it is allowed, but not guaranteed, for the optic operation to return the input as is.

On composability

A lot of libraries these days claim to be composable. Is any collection of functions composable? In the opinion of the author of this library, in order for something to be called "composable", a couple of conditions must be fulfilled:

  1. There must be an operation or operations that perform composition.
  2. There must be simple laws on how compositions behave.

Conversely, if there is no operation to perform composition or there are no useful simplifying laws on how compositions behave, then one sho

14.17.0

5 years ago

14.16.0

5 years ago

14.15.0

5 years ago

14.14.0

5 years ago

14.13.0

5 years ago

14.12.0

5 years ago

14.11.1

6 years ago

14.11.0

6 years ago

14.10.0

6 years ago

14.9.1

6 years ago

14.9.0

6 years ago

14.8.0

6 years ago

14.7.0

6 years ago

14.6.0

6 years ago

14.5.0

6 years ago

14.4.0

6 years ago

14.3.0

6 years ago

14.2.1

6 years ago

14.2.0

6 years ago

14.1.0

6 years ago

14.0.0

6 years ago

14.0.0-0

6 years ago

13.16.0

6 years ago

13.15.0

6 years ago

13.14.0

6 years ago

13.13.2

6 years ago

13.13.1

6 years ago

13.13.0

6 years ago

13.12.0

6 years ago

13.11.0

6 years ago

13.10.0

6 years ago

13.9.0

6 years ago

13.8.0

6 years ago

13.7.4

6 years ago

13.7.3

6 years ago

13.7.2

6 years ago

13.7.1

6 years ago

13.7.0

6 years ago

13.6.2

6 years ago

13.6.1

6 years ago

13.6.0

6 years ago

13.5.0

6 years ago

13.4.0

6 years ago

13.3.0

6 years ago

13.2.1

6 years ago

13.2.0

6 years ago

13.1.1

6 years ago

13.1.0

6 years ago

13.0.0

7 years ago

12.1.0

7 years ago

12.0.1

7 years ago

12.0.0

7 years ago

11.22.2

7 years ago

11.22.1

7 years ago

11.22.0

7 years ago

11.21.0

7 years ago

11.20.0

7 years ago

11.19.0

7 years ago

11.18.0

7 years ago

11.17.0

7 years ago

11.16.1

7 years ago

11.16.0

7 years ago

11.15.0

7 years ago

11.14.1

7 years ago

11.14.0

7 years ago

11.13.0

7 years ago

11.12.0

7 years ago

11.11.0

7 years ago

11.10.0

7 years ago

11.9.1

7 years ago

11.9.0

7 years ago

11.8.0

7 years ago

11.7.1

7 years ago

11.7.0

7 years ago

11.6.0

7 years ago

11.5.0

7 years ago

11.4.0

7 years ago

11.3.0

7 years ago

11.2.0

7 years ago

11.1.0

7 years ago

11.0.0

7 years ago

10.4.0

7 years ago

10.3.3

7 years ago

10.3.2

7 years ago

10.3.1

7 years ago

10.3.0

7 years ago

10.2.0

7 years ago

10.1.1

7 years ago

10.1.0

7 years ago

10.0.4

7 years ago

10.0.3

7 years ago

10.0.2

7 years ago

10.0.1

7 years ago

10.0.0

7 years ago

9.8.0

7 years ago

9.7.0

7 years ago

9.6.1

7 years ago

9.6.0

7 years ago

9.5.0

7 years ago

9.4.1

7 years ago

9.4.0

7 years ago

9.3.0

7 years ago

9.2.1

7 years ago

9.2.0

7 years ago

9.1.1

7 years ago

9.1.0

7 years ago

9.0.3

7 years ago

9.0.2

7 years ago

9.0.1

7 years ago

9.0.0

7 years ago

8.1.1

7 years ago

8.1.0

7 years ago

8.0.1

7 years ago

8.0.0

7 years ago

7.4.0

7 years ago

7.3.0

7 years ago

7.2.2

7 years ago

7.2.1

7 years ago

7.2.0

7 years ago

7.1.0

7 years ago

7.0.0

7 years ago

6.1.0

7 years ago

6.0.0

7 years ago

5.3.2

7 years ago

5.3.1

7 years ago

5.3.0

7 years ago

5.2.0

7 years ago

5.1.0

7 years ago

5.0.1

7 years ago

5.0.0

7 years ago

4.1.1

7 years ago

4.1.0

7 years ago

4.0.2

7 years ago

4.0.1

7 years ago

4.0.0

7 years ago

3.9.4

7 years ago

3.9.3

7 years ago

3.9.2

7 years ago

3.9.1

7 years ago

3.9.0

8 years ago

3.8.2

8 years ago

3.8.1

8 years ago

3.8.0

8 years ago

3.7.0

8 years ago

3.6.3

8 years ago

3.6.2

8 years ago

3.6.1

8 years ago

3.6.0

8 years ago

3.5.0

8 years ago

3.4.2

8 years ago

3.4.1

8 years ago

3.4.0

8 years ago

3.3.1

8 years ago

3.3.0

8 years ago

3.2.0

8 years ago

3.1.0

8 years ago

3.0.0

8 years ago

2.2.1

8 years ago

2.2.0

8 years ago

2.1.0

8 years ago

2.0.0

8 years ago

1.4.1

8 years ago

1.4.0

8 years ago

1.3.1

8 years ago

1.3.0

8 years ago

1.2.0

8 years ago

1.1.2

8 years ago

1.1.1

8 years ago

1.1.0

8 years ago

1.0.0

8 years ago

0.7.0

8 years ago

0.6.0

8 years ago

0.5.1

8 years ago

0.5.0

8 years ago

0.4.0

8 years ago

0.3.0

8 years ago

0.2.1

8 years ago

0.2.0

8 years ago

0.1.1

8 years ago

0.1.0

8 years ago