partial.lenses
v14.17.0
Published
Partial lenses is a comprehensive, high-performance optics library for JavaScript
Downloads
20,311
Maintainers
Readme
≡ ▶ Partial Lenses ·
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!
≡ ▶ Contents
- Tutorial
- The why of optics
- Reference
- Stable subset
- Additional libraries
- Optics
- On partiality
- On indexing
- On immutability
- On composability
- On lens laws
- Operations on optics
L.assign(optic, object, maybeData) ~> maybeData
v11.13.0L.disperse(optic, [...maybeValues], maybeData) ~> maybeData
v14.6.0L.modify(optic, (maybeValue, index) => maybeValue, maybeData) ~> maybeData
v2.2.0L.modifyAsync(optic, (maybeValue, index) => maybeValuePromise, maybeData) ~> maybeDataPromise
v13.12.0L.remove(optic, maybeData) ~> maybeData
v2.0.0L.set(optic, maybeValue, maybeData) ~> maybeData
v1.0.0L.traverse(algebra, (maybeValue, index) => operation, optic, maybeData) ~> operation
v10.0.0
- Nesting
L.compose(...optics) ~> optic
or[...optics]
v1.0.0L.flat(...optics) ~> optic
v13.6.0
- Recursing
- Adapting
L.choices(optic, ...optics) ~> optic
v11.10.0L.choose((maybeValue, index) => optic) ~> optic
v1.0.0- L.cond(...[(maybeValue, index) => testable, consequentOptic][, [alternativeOptic]]) ~> optic v13.1.0
- L.condOf(traversal, ...[(maybeValue, index) => testable, consequentOptic][, [alternativeOptic]]) ~> optic v13.5.0
L.ifElse((maybeValue, index) => testable, optic, optic) ~> optic
v13.1.0L.orElse(backupOptic, primaryOptic) ~> optic
v2.1.0
- Indices
L.joinIx(optic) ~> optic
v13.15.0L.mapIx((index, maybeValue) => index) ~> optic
v13.15.0L.reIx(optic) ~> optic
v14.10.0L.setIx(index) ~> optic
v13.15.0L.skipIx(optic) ~> optic
v13.15.0L.tieIx((innerIndex, outerIndex) => index, optic) ~> optic
v13.15.0
- Debugging
L.getLog(lens, maybeData) ~> maybeValue
v13.14.0L.log(...labels) ~> optic
v3.2.0
- Internals
L.Identity ~> Monad
v13.7.0L.IdentityAsync ~> Monadish
v13.12.0L.Select ~> Applicative
v14.0.0L.toFunction(optic) ~> optic
v7.0.0
- Transforms
- Traversals
- Creating new traversals
- Traversals and combinators
L.children ~> traversal
v13.3.0L.elems ~> traversal
v7.3.0L.elemsTotal ~> traversal
v13.11.0L.entries ~> traversal
v11.21.0L.flatten ~> traversal
v11.16.0L.keys ~> traversal
v11.21.0L.keysEverywhere ~> traversal
v14.12.0L.leafs ~> traversal
v13.3.0L.limit(count, traversal) ~> traversal
v14.10.0L.matches(/.../g) ~> traversal
v10.4.0L.offset(count, traversal) ~> traversal
v14.10.0L.query(...traversals) ~> traversal
v13.6.0L.satisfying((maybeValue, index) => testable) ~> traversal
v13.3.0L.subseq(begin, end, traversal) ~> traversal
v14.10.0L.values ~> traversal
v7.3.0L.whereEq({prop: value, ...props}) ~> traversal
v14.16.0
- Querying
- Folds over traversals
L.all((maybeValue, index) => testable, traversal, maybeData) ~> boolean
v9.6.0L.all1((maybeValue, index) => testable, traversal, maybeData) ~> boolean
v14.4.0L.and(traversal, maybeData) ~> boolean
v9.6.0L.and1(traversal, maybeData) ~> boolean
v14.4.0L.any((maybeValue, index) => testable, traversal, maybeData) ~> boolean
v9.6.0L.collect(traversal, maybeData) ~> [...values]
v3.6.0L.collectAs((maybeValue, index) => maybeValue, traversal, maybeData) ~> [...values]
v7.2.0L.collectTotal(traversal, maybeData) ~> [...maybeValues]
v14.6.0L.collectTotalAs((maybeValue, index) => maybeValue, traversal, maybeData) ~> [...maybeValues]
v14.6.0L.concat(monoid, traversal, maybeData) ~> value
v7.2.0L.concatAs((maybeValue, index) => value, monoid, traversal, maybeData) ~> value
v7.2.0L.count(traversal, maybeData) ~> number
v9.7.0L.countIf((maybeValue, index) => testable, traversal, maybeData) ~> number
v11.2.0L.counts(traversal, maybeData) ~> map
v11.21.0L.countsAs((maybeValue, index) => any, traversal, maybeData) ~> map
v11.21.0L.foldl((value, maybeValue, index) => value, value, traversal, maybeData) ~> value
v7.2.0L.foldr((value, maybeValue, index) => value, value, traversal, maybeData) ~> value
v7.2.0L.forEach((maybeValue, index) => undefined, traversal, maybeData) ~> undefined
v11.20.0L.forEachWith(() => context, (context, maybeValue, index) => undefined, traversal, maybeData) ~> context
v13.4.0L.get(traversal, maybeData) ~> maybeValue
v2.2.0L.getAs((maybeValue, index) => maybeValue, traversal, maybeData) ~> maybeValue
v14.0.0L.isDefined(traversal, maybeData) ~> boolean
v11.8.0L.isEmpty(traversal, maybeData) ~> boolean
v11.5.0L.join(string, traversal, maybeData) ~> string
v11.2.0L.joinAs((maybeValue, index) => maybeString, string, traversal, maybeData) ~> string
v11.2.0L.maximum(traversal, maybeData) ~> maybeValue
v7.2.0L.maximumBy(keyLens, traversal, maybeData) ~> maybeValue
v11.2.0L.mean(traversal, maybeData) ~> number
v11.17.0L.meanAs((maybeValue, index) => maybeNumber, traversal, maybeData) ~> number
v11.17.0L.minimum(traversal, maybeData) ~> maybeValue
v7.2.0L.minimumBy(keyLens, traversal, maybeData) ~> maybeValue
v11.2.0L.none((maybeValue, index) => testable, traversal, maybeData) ~> boolean
v11.6.0L.or(traversal, maybeData) ~> boolean
v9.6.0L.product(traversal, maybeData) ~> number
v7.2.0L.productAs((maybeValue, index) => number, traversal, maybeData) ~> number
v11.2.0- ~~
L.select(traversal, maybeData) ~> maybeValue
v9.8.0~~ - ~~
L.selectAs((maybeValue, index) => maybeValue, traversal, maybeData) ~> maybeValue
v9.8.0~~ L.sum(traversal, maybeData) ~> number
v7.2.0L.sumAs((maybeValue, index) => number, traversal, maybeData) ~> number
v11.2.0
- Lenses
- Creating new lenses
L.foldTraversalLens((traversal, maybeData) => maybeValue, traversal) ~> lens
v11.5.0L.getter((maybeData, index) => maybeValue) ~> lens
v13.16.0L.lens((maybeData, index) => maybeValue, (maybeValue, maybeData, index) => maybeData) ~> lens
v1.0.0L.partsOf(traversal, ...traversals) ~> lens
v14.6.0L.setter((maybeValue, maybeData, index) => maybeData) ~> lens
v10.3.0
- Enforcing invariants
- Lensing array-like objects
- ~~
L.append ~> lens
v1.0.0~~ L.cross([...lenses]) ~> lens
v14.3.0L.filter((maybeValue, index) => testable) ~> lens
v1.0.0L.find((maybeValue, index, {hint: index}) => testable[, {hint: index}]) ~> lens
v1.0.0L.findWith(optic[, {hint: index}]) ~> optic
v1.0.0L.first ~> lens
v13.1.0L.index(elemIndex) ~> lens
orelemIndex
v1.0.0L.last ~> lens
v9.8.0L.prefix(maybeEnd) ~> lens
v11.12.0L.slice(maybeBegin, maybeEnd) ~> lens
v8.1.0L.suffix(maybeBegin) ~> lens
v11.12.0
- ~~
- Lensing objects
L.pickIn({prop: lens, ...props}) ~> lens
v11.11.0L.prop(propName) ~> lens
orpropName
v1.0.0L.props(...propNames) ~> lens
v1.4.0L.propsExcept(...propNames) ~> lens
v14.11.0- ~~
L.propsOf(object) ~> lens
v11.13.0~~ L.removable(...propNames) ~> lens
v9.2.0
- Lensing strings
L.matches(/.../) ~> lens
v10.4.0
- Providing defaults
L.valueOr(valueOut) ~> lens
v3.5.0
- Transforming data
- Inserters
L.appendTo ~> lens
v14.14.0L.assignTo ~> lens
v14.14.0L.prependTo ~> lens
v14.14.0
- Creating new lenses
- Isomorphisms
- Operations on isomorphisms
- Creating new isomorphisms
L.iso(maybeData => maybeValue, maybeValue => maybeData) ~> isomorphism
v5.3.0L.mapping([patternFwd, patternBwd] | (...variables) => [patternFwd, patternBwd]) ~> isomorphism
v14.8.0L._ ~> pattern
v14.8.0
L.mappings([...[patternFwd, patternBwd]] | (...variables) => [...[patternFwd, patternBwd]]) ~> isomorphism
v14.8.0L.pattern(pattern | (...variables) => pattern) ~> isomorphism
v14.13.0L.patterns([...patterns] | (...variables) => [...patterns]) ~> isomorphism
v14.13.0
- Isomorphism combinators
L.alternatives(isomorphism, ...isomorphisms) ~> isomorphism
v14.7.0L.applyAt(elementsOptic, isomorphism) ~> isomorphism
v14.9.0L.attemptEveryDown(isomorphism) ~> isomorphism
v14.13.0L.attemptEveryUp(isomorphism) ~> isomorphism
v14.13.0L.attemptSomeDown(isomorphism) ~> isomorphism
v14.13.0L.conjugate(contextIsomorphism, isomorphism) ~> isomorphism
v14.9.0L.fold(isomorphism) ~> isomorphism
v14.13.0L.inverse(isomorphism) ~> isomorphism
v4.1.0L.iterate(isomorphism) ~> isomorphism
v14.3.0L.orAlternatively(backupIsomorphism, primaryIsomorphism) ~> isomorphism
v14.7.0L.unfold(isomorphism) ~> isomorphism
v14.13.0
- Basic isomorphisms
- Array isomorphisms
L.array(isomorphism) ~> isomorphism
v11.19.0L.arrays(isomorphism) ~> isomorphism
v14.13.0L.groupBy(keyLens) ~> isomorphism
v14.13.0L.indexed ~> isomorphism
v11.21.0L.reverse ~> isomorphism
v11.22.0L.singleton ~> isomorphism
v11.18.0L.ungroupBy(keyLens) ~> isomorphism
v14.13.0L.unzipWith1(isomorphism) ~> isomorphism
v14.13.0L.zipWith1(isomorphism) ~> isomorphism
v14.13.0
- Object isomorphisms
L.disjoint(propName => propName) ~> isomorphism
v13.13.0L.keyed ~> isomorphism
v11.21.0L.multikeyed ~> isomorphism
v14.1.0
- Standard isomorphisms
- Standardish isomorphisms
L.querystring ~> isomorphism
v14.2.0
- String isomorphisms
- Arithmetic isomorphisms
L.add(number) ~> isomorphism
v13.9.0L.divide(number) ~> isomorphism
v13.9.0L.multiply(number) ~> isomorphism
v13.9.0L.negate ~> isomorphism
v13.9.0L.subtract(number) ~> isomorphism
v13.9.0
- Interop
- Auxiliary
- Examples
- Deepening topics
- Advanced topics
- Background
- Contributing
≡ ▶ 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,
L.compose(...)
can be abbreviated as an array[...]
,L.prop(propName)
can be abbreviated aspropName
, andL.set(l, undefined, s)
can be abbreviated asL.remove(l, s)
.
≡ ▶ 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:
L.compose(...optics) ~> optic
is stable with the exception that one must not depend on being able to compose optics with ordinary functions. Also, the use of arrays to denote composition is not part of the stable subset. Note thatL.compose()
is guaranteed to be equivalent to theL.identity
optic.L.get(lens, maybeData) ~> maybeValue
is stable without limitations.L.lens(maybeData => maybeValue, (maybeValue, maybeData) => maybeData) ~> lens
is stable with the exception that one must not depend on the user specified getter and setter functions being passed more than 1 and 2 arguments, respectively, and one must make no assumptions about any extra parameters being passed.L.modify(optic, maybeValue => maybeValue, maybeData) ~> maybeData
is stable with the exception that one must not depend on the user specified function being passed more than 1 argument and one must make no assumptions about any extra parameters being passed.L.remove(optic, maybeData) ~> maybeData
is stable without limitations.L.set(optic, maybeValue, maybeData) ~> maybeData
is stable without limitations.
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 operation—the 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:
- There must be an operation or operations that perform composition.
- 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