My attempt to understand and create functional lenses in javascript. There are some laws and de facto standards
- get after set should return the modified part
- set after get should return the unmodified whole
- set after set should overwrite the part
- create should take a getter and a setter
- lenses should be left to right composable
- there should be a
view
,set
andover
functions to work with lenses - there should be
index
,prop
andpath
functions to create lenses
Just create a getter and setter object
export const Lens = {
create(getter, setter) {
return {getter, setter}
},
view(lens, w) {
return lens.getter(w);
},
set(lens, v, w) {
return lens.setter(w)(v);
},
over(lens, m, w) {
return lens.setter(w)(m(lens.getter(w)));
}
}
The way this works is you create a lens by specifying a getter and a setter:
let whole = {foo: 2}
let fooLens = Lens.create(w => w.foo, w => v => ({...w, foo: v}));
Which you can then use to get and set a value:
Lens.view(fooLens, whole); // => 2
Lens.set(fooLens, 4, whole); // => {foo: 4}
Lens.over(fooLens, x => 2*x, whole); // => {foo: 4}
This works but it is not composable. To make it composable, create
should at least return a function.
export const Lens = {
create(getter, setter) {
return () => ({getter, setter})
},
view(lens, w) {
return lens().getter(w);
},
set(lens, v, w) {
return lens().setter(w)(v);
},
over(lens, m, w) {
return lens().setter(w)(m(lens().getter(w)));
}
}
This is composable alright, but really does not do what we want. Let's look at what argument the lens gets passed in when it is composed.
Suppose we have an object
let whole = {foo: {bar: 2}}
a lens for the foo
property and a lens for the bar property
let fooLens = Lens.create(w => w.foo, w => v => ({...w, foo: v}));
let barLens = Lens.create(w => w.bar, w => v => ({...w, bar: v}));
and we want it to compose left to right
let fooBarLens = compose(fooLens, barLens);
so the fooLens
gets as its argument the result of the barLens
function, which is a getter-setter pair. Lets call this inner
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter,
setter
}
}
},
//...
}
Let's look at getter composition. The fooLens
function gets the result of the barLens
function, which contains a getter for bar
. This getter for bar
expects the value of foo
as the whole, which we can get with the getter for foo
:
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter: (w) => inner.getter(getter(w)),
setter
}
}
},
//...
}
Pause for a moment and make sure you understand. It took me a while.
We have a broken implementation now. Let's fix that
const id = x => x;
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter: (w) => inner.getter(getter(w)),
setter
}
}
},
view(lens, w) {
return lens({getter: id}).getter(w);
},
set(lens, v, w) {
return lens({}).setter(w)(v);
},
over(lens, m, w) {
return lens({}).setter(w)(m(lens({getter: id}).getter(w)));
}
}
As the lens now expects an argument, we pass in the identity function as a getter for view
and over
. This makes sure that the composition in create
still works. This just reduces to (w) => getter(w)
, which is what we had before.
Now get the setters composable
Again, we have fooLens
and barLens
let whole = {foo: {bar: 2}}
let fooLens = Lens.create(w => w.foo, w => v => ({...w, foo: v}));
let barLens = Lens.create(w => w.bar, w => v => ({...w, bar: v}));
For setting values in a composed way, barLens
should operate on the result of fooLens
. Remember that the barLens
getter-setter pair is passed in as the inner
argument in the fooLens
function. The result of this setter is the value to be used for the fooLens
setter.
So the setter is currently just
setter: (w) => (v) => setter(w)(v);
now, let the inner setter operate on the result of the outer getter
setter: (w) => (v) => inner.setter(getter(w))(v)
does this make sense? The outer getter returns the inner whole foo
, which is {bar: 2}
. Then the inner setter operates on it.
However, this results in the modified inner value. We are missing one step. Setting this modified inner value in the outer whole
setter: (w) => (v) => setter(w)(inner.setter(getter(w))(v))
which results in the following implementation:
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter: (w) => inner.getter(getter(w)),
setter: (w) => (v) => setter(w)(inner.setter(getter(w))(v))
}
}
},
//...
}
We again have a broken implementation now. Let's fix that. We need to pass something in as the inner setter. What should that be? Well, the inner setter should just return v
, so that
setter: (w) => (v) => setter(w)(inner.setter(getter(w))(v))
becomes equivalent to
setter: (w) => (v) => setter(w)(v)
which is what we had before. To do this, the inner setter needs to be
setter: (w) => (v) => v
or just
setter: (w) => id
so
const id = x => x;
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter: (w) => inner.getter(getter(w)),
setter: (w) => (v) => setter(w)(inner.setter(getter(w))(v))
}
}
},
view(lens, w) {
return lens({getter: id}).getter(w);
},
set(lens, v, w) {
return lens({setter: _ => id}).setter(w)(v);
},
over(lens, m, w) {
return lens({setter: _ => id}).setter(w)(m(lens({getter: id}).getter(w)));
}
}
Let's refactor a bit
const id = x => x;
const idAccessor = {
getter: id,
setter: _ => id
};
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter: (w) => inner.getter(getter(w)),
setter: (w) => (v) => setter(w)(inner.setter(getter(w))(v))
}
}
},
view(lens, w) {
return lens(idAccessor).getter(w);
},
set(lens, v, w) {
return lens(idAccessor).setter(w)(v);
},
over(lens, m, w) {
return lens(idAccessor).setter(w)(m(lens(idAccessor).getter(w)));
}
}
Index should get an index from an array. When setting, it should return a new array.
index(n) {
return Lens.create(w => w[n], w => v => [...w.slice(0,n), v, ...w.slice(n+1)]);
}
Props should return a value at a key from an object. When setting, it should return a new object.
prop(k) {
return Lens.create(w => w[k], w => v => ({...w, [k]: v}));
}
Path is just repeated application of prop
. For this we need a compose function. A simple implementation is this:
function compose(...fns) {
if (fns.length === 0) {
return x => x;
}
return fns.reduce((f, g) => (...args) => f(g(...args)));
}
As we already made sure lens composition works, the implementation of path
is easy
path(p) {
return compose(...p.split('.').map(Lens.prop));
}
So everything together is this
function compose(...fns) {
if (fns.length === 0) {
return x => x;
}
return fns.reduce((f, g) => (...args) => f(g(...args)));
}
const id = x => x;
const idAccessor = {
getter: id,
setter: _ => id
};
export const Lens = {
create(getter, setter) {
return (inner) => {
return {
getter: (w) => inner.getter(getter(w)),
setter: (w) => (v) => setter(w)(inner.setter(getter(w))(v))
}
}
},
view(lens, w) {
return lens(idAccessor).getter(w);
},
set(lens, v, w) {
return lens(idAccessor).setter(w)(v);
},
over(lens, m, w) {
return lens(idAccessor).setter(w)(m(lens(idAccessor).getter(w)));
},
index(n) {
return Lens.create(w => w[n], w => v => [...w.slice(0,n), v, ...w.slice(n+1)]);
},
prop(k) {
return Lens.create(w => w[k], w => v => ({...w, [k]: v}));
},
path(p) {
return compose(...p.split('.').map(Lens.prop));
}
}