A type safe functional lens implemented via proxy
- Install
- API
- Usage
- Setup
- Setting values with the
.set
method - Retrieving values with the
.get
method - Setting values and continuing with the
.let
method - Pegging lenses with the
.peg
method - Modifying lenses with the
.mod
method - Manipulating immutable arrays with the
.del
and.put
methods - Modifying array items with the
.map
method - Traversing arrays using the
.tap
method - Using abstract lenses
- Recursive abstract lenses
yarn add proxy-lens
proxy-lens
exports the following members:
import {
lens,
Getter,
Setter,
ProxyLens,
ProxyTraversal,
BaseLens,
ArrayLens,
} from 'proxy-lens';
It's the single factory function for creating proxy lenses, it takes a single optional argument which would serve both as the lens root type and value. If this argument is omitted then a type parameter is required and the lens becomes abstract (a root value would need to be passed later).
lens({ a: 1 }) // :: ProxyLens<{ a: boolean }, { a: boolean }>
lens<Person>() // :: ProxyLens<Person, Person>
Proxy lenses provide two sets of methods for each level in the chain (including the root level), a base set of methods and an array-specific set of methods. The first is offered for every property and the second only to array properties. Let's see them in detail.
Any function that implements the signature (a: A) => B
.
Any function that implements the signature (b: B, a: A) => A
.
A union between the interfaces of BaseLens<A, B>
and ArrayLens<A, B>
.
A special kind of ProxyLens
used to represent traversals.
type BaseLens<A, B> = {
get(target?: A): B
set(value: B, target?: A): A
let(value: B): ProxyLens<A, A>
peg(get: Getter<A, B>): ProxyLens<A, A>
mod(get: Getter<B, B>): ProxyLens<A, A>
iso<C>(get: Getter<B, C>, set: Setter<B, C>): ProxyLens<A, C>
}
Base interface shared by all concrete or abstract lenses.
Gets a value via the current lens, the optional first parameter is either a given root value (for abstract lenses) or the root value that was passed to lens
.
lens({ a: { b: true }}).a.b.get() // :: true
lens<{ a: { b: boolean }}>().a.b.get({ a: { b: true }}) // :: true
Sets a value via the current lens, the first parameter is the value to set and the optional second parameter is either a given root value (for abstract lenses) or the root value that was passed to lens
. It works on immutable root values and it always returns a copy of it containing the modifications.
lens({ a: { b: 'hello' }}).a.b.set('bye') // :: { a: { b: 'bye' }}
lens<{ a: { b: string }}>().a.b.set('bye', { a: { b: 'hello' }}) // :: { a: { b: 'bye' }}
Works similarly to .set
but instead of returning the root value it returns a new lens of the root value, this way after using it other methods can be chained on it.
lens({ a: false, b: true })
.a.let(true)
.b.let(false).get() // :: { a: true, b: false }
lens<{ a: boolean, b: boolean }>()
.a.let(true)
.b.let(false).get({ a: false, b: true }) // :: { a: true, b: false }
lens({ a: false, b: true })
.a.let(true)
.a.set(false) // :: { a: false, b: true } (overriden)
lens<{ a: boolean, b: boolean }>()
.a.let(true)
.a.set(false, { a: false, b: true }) // :: { a: false, b: true } (overriden)
Pegs a getter (or another lens) to a given lens with the same signature. Like .let
returns a lens focused on the root so other operations may be chained.
lens({ a: false, b: true })
.a.peg(lens<{ a: boolean, b: boolean }>().b)
.get() // :: { a: true, b: true }
lens({ a: false, b: true })
.a.peg(lens<{ a: boolean, b: boolean }>().b)
.b.set(false) // :: { a: false, b: false }
lens<{ a: boolean, b: boolean }>()
.a.peg(lens<{ a: boolean, b: boolean }>().b)
.get({ a: false, b: true }) // :: { a: true, b: true }
lens<{ a: boolean, b: boolean }>()
.a.peg(lens<{ a: boolean, b: boolean }>().b)
.b.set(false) // :: { a: false, b: false }
It's a method used to modify lens outputs, suited for same-type value updates depending on arbitrary conditions. Takes a getter (or another lens) and like .let
returns a lens focused on the root so other operations may be chained.
lens({ a: { b: true }}).mod(
(b: boolean): boolean => !b,
).get() // :: 'false'
lens<{ a: { b: boolean }}>().mod(
(b: boolean): boolean => !b,
).get({ a: { b: true }}) // :: 'false'
It's a method used to build modifications between two types A
and C
through a common type B
. It takes one getter (or a lens, because it works as a getter) and an optional setter in case we want it to be a two way transformation. It then returns a new lens from the previous root type A
to the new target type C
.
lens({ a: { b: true }}).iso(
(b: boolean): string => String(b),
(c: string): boolean => c === 'true'
).get() // :: 'true'
lens({ a: { b: true }}).iso(
(b: boolean): string => String(b),
(c: string): boolean => c === 'true'
).set('true') // :: { a: { b: true }}
lens<{ a: { b: boolean }}>().iso(
(b: boolean): string => String(b),
(c: string): boolean => c === 'true'
).get({ a: { b: true }}) // :: 'true'
lens({ a: { b: boolean }}).iso(
(b: boolean): string => String(b),
(c: string): boolean => c === 'true'
).set('true', { a: { b: true }}) // :: { a: { b: true }}
type ArrayLens<A, B> = {
del(index: number, a?: A): ProxyLens<A, A>
put(index: number, b: B | B[], a?: A): ProxyLens<A, A>
}
This interface is available for lenses focused on array types.
Used to perform a deletion of a given array item by index. It takes the given index to delete and like .let
returns a lens focused on the root so other operations may be chained.
lens({ a: [{ b: 'delme' }] }).a.del(0).get() // :: { a: [] }
lens<{ a: { b: string }[] }>().a.del(0).get({ a: [{ b: 'delme' }] }) // :: { a: [] }
Used to perform a non-destructive insert of a one or more array items by index or by negative offset (-1
being the last position and so on), it takes the value to insert and like .let
returns a lens focused on the root so other operations may be chained.
lens({ a: ['keep'] })
.a.put(0, 'insert').get() // :: { a: ['insert', 'keep'] }
lens({ a: ['keep'] })
.a.put(-1, 'insert').get() // :: { a: ['keep', 'insert'] }
lens({ a: ['keep'] })
.a.put(0, ['insert', 'many']).get() // :: { a: ['insert', 'many', 'keep'] }
lens({ a: ['keep'] })
.a.put(-1, ['insert', 'many']).get() // :: { a: ['keep', 'insert', 'many'] }
lens<{ a: string[] }>()
.a.put(0, 'insert', { a: ['keep'] }).get() // :: { a: ['insert', 'keep'] }
lens<{ a: string[] }>()
.a.put(-1, 'insert', { a: ['keep'] }).get() // :: { a: ['keep', 'insert'] }
lens<{ a: string[] }>()
.a.put(0, ['insert', 'many'], { a: ['keep'] }).get() // :: { a: ['insert', 'many', 'keep'] }
lens<{ a: string[] }>()
.a.put(-1, ['insert', 'many'], { a: ['keep'] }).get() // :: { a: ['keep', 'insert', 'many'] }
It works like the .mod
method but for array items.
lens({ a: ['map'] })
.a.map((str) => str.toUpperCase()).get() // :: { a: ['MAP'] }
lens<{ a: string[] }>()
.a.map((str) => str.toUpperCase())
.get({ a: ['map'] }) // :: { a: ['MAP'] }
It's used to create traversal lenses from arrays, traversal lenses work like regular lenses but they are focused on the collection of values of a given property. It takes an optional getter that returns a boolean which can be used to filter the collection of values.
lens({ a: [{ b: 'traversal'}, { b: 'lens' }] })
.a.tap().b.get() // :: ['traversal', 'lens']
lens<{ a: { b: string }[] }>()
.a.tap()
.b.get({ a: [{ b: 'traversal'}, { b: 'lens' }] }) // :: ['traversal', 'lens']
lens({ a: [{ b: 'traversal'}, { b: 'lens' }] })
.a.tap(({ b }) => b[0] === 'l').b.get() // :: ['lens']
lens<{ a: { b: string }[] }>()
.a.tap(({ b }) => b[0] === 'l').b.get() // :: ['lens']
.b.get({ a: [{ b: 'traversal'}, { b: 'lens' }] }) // :: ['lens']
lens({ a: [{ b: 'traversal'}, { b: 'lens' }] })
.a.tap()
.b.set(['modified', 'value']) // :: { a: [ { b: 'modified' }, { b: 'value' } ] }
lens({ a: { b: string }[] })
.a.tap().b.set(
['modified', 'value'],
{ a: [{ b: 'traversal'}, { b: 'lens' }] }
) // :: { a: [ { b: 'modified' }, { b: 'value' } ] }
lens({ a: [{ b: 'traversal'}, { b: 'lens' }] })
.a.tap(({ b }) => b[0] === 'l')
.b.set(['modified']) // :: { a: [ { b: 'traversal' }, { b: 'modified' } ] }
lens({ a: { b: string }[] })
.a.tap(({ b }) => b[0] === 'l')
.b.set(
['modified'],
{ a: [{ b: 'traversal'}, { b: 'lens' }] }
) // :: { a: [ { b: 'traversal' }, { b: 'modified' } ] }
Here's a throughout usage example.
First we need to import the lens
function from this library.
import { lens } from 'proxy-lens';
We can create now some testing types that we will use through the example.
type Hobby = {
name: string;
};
type Street = {
name: string;
};
type Address = {
city: string;
street: Street;
zip?: number;
};
type Company = {
name: string;
address?: Address;
};
type Person = {
name: string;
company?: Company;
hobbies?: Hobby[];
};
Then let's create some people.
const john: Person = {
name: 'John Wallace'
};
const mary: Person = {
name: 'Mary Sanchez'
};
const michael: Person = {
name: 'Michael Collins'
};
Setting values with the .set
method
Lets now assign some of them a company via the proxy lens .set
method, the comment next the operation displays what will be returned.
import { strict as assert } from 'assert'; // to check the results
const employedJohn = lens(john).company.set({
name: 'Microsoft',
address: { city: 'Redmond' }
});
assert.deepEqual(employedJohn, {
name: 'John Wallace',
company: { name: 'Microsoft', address: { city: 'Redmond' } },
})
const employedMichael = lens(michael).company.set({
name: 'Google'
});
assert.deepEqual(employedMichael, {
name: 'Michael Collins',
company: { name: 'Google' }
});
Retrieving values with the .get
method
Now for example we can fetch the related companies of all using the .get
method, even if some have null companies.
const employedJohnCompany = lens(employedJohn).company.name.get();
assert.equal(employedJohnCompany, 'Microsoft');
const unemployedMaryCompany = lens(mary).company.name.get();
assert.equal(unemployedMaryCompany, undefined);
const employedMichaelCompany = lens(employedMichael).company.name.get();
assert.equal(employedMichaelCompany, 'Google');
Setting values and continuing with the .let
method
We can use the .let
method to perform sets on different slices of the parent object, at the end of the edition we can call .get
to return the parent value (otherwise we keep getting the parent lens).
const localizedEmployedJohn = lens(employedJohn)
.company.name.let('Apple')
.company.address.city.let('Cupertino')
.get();
assert.deepEqual(localizedEmployedJohn, {
name: 'John Wallace',
company: { name: 'Apple', address: { city: 'Cupertino' } }
});
const localizedEmployedMary = lens(mary)
.company.name.let('Microsoft')
.company.address.let({
city: 'Redmond',
street: { name: '15010 NE 36th St' },
zip: 98052
})
.get();
assert.deepEqual(localizedEmployedMary, {
name: 'Mary Sanchez',
company: {
name: 'Microsoft',
address: {
city: 'Redmond',
street: { name: '15010 NE 36th St' },
zip: 98052
}
}
});
Pegging lenses with the .peg
method
Sometimes we want a lens to depend on other lens, an easy way to do this is to use the .peg
method, where we can pass another lens so the value of the first is derived from the second.
const freelancerJohn = lens(john).company.name.peg(lens<Person>().name).get()
assert.deepEqual(freelancerJohn, {
name: 'John Wallace',
company: { name: 'John Wallace' },
})
Please note that we used a one-way mod to append a string to John's name.
Modifying lenses with the .mod
method
We can upgrade the previously generated object by transforming the company name so it reflects that John is now running his own startup.
const enterpreneurJohn = lens(freelancerJohn)
.company.name.mod((name): string => `${name} Inc.`)
.get()
assert.deepEqual(enterpreneurJohn, {
name: 'John Wallace',
company: { name: 'John Wallace Inc.' },
})
Transforming lenses with the .iso
method
Transforming lenses from one type to another and viceversa is relatively simple with this method, we just need to provide a getter and a setter that produce the required type for each side of the transformation.
const nameSplitterIso = lens<Person>().name.iso(
(name): { first: string; last: string } => ({
first: name.split(' ')[0],
last: name.split(' ').slice(1).join(' '),
}),
({ first, last }): string => `${first} ${last}`,
)
const johnSplitName = nameSplitterIso.get(john)
assert.deepEqual(johnSplitName, { first: 'John', last: 'Wallace' })
const johnIsNowRobert = nameSplitterIso.set(
{ first: 'Robert', last: 'Wilcox' },
john,
)
assert.deepEqual(johnIsNowRobert, { name: 'Robert Wilcox' })
Aside of support for array access by index, there's also three extra operations for array types to manipulate their contents based on a given index, these are .del
to delete an item at a given index and .put
to insert one or more items at a given place (without overwriting other items). Let's see how they're used.
const fisherMary = lens(mary).hobbies[0].name.set('Fishing');
assert.deepEqual(fisherMary, {
name: 'Mary Sanchez',
hobbies: [{ name: 'Fishing' }]
});
const boredMary = lens(mary)
.hobbies.del(0)
.get();
assert.deepEqual(boredMary, { name: 'Mary Sanchez', hobbies: [] });
const sailorMary = lens(mary)
.hobbies.put(0, { name: 'Fishing' })
.hobbies.put(-1, { name: 'Boating' })
.hobbies.put(1, [{ name: 'Swimming' }, { name: 'Rowing' }])
.get();
assert.deepEqual(sailorMary, {
name: 'Mary Sanchez',
hobbies: [
{ name: 'Fishing' },
{ name: 'Swimming' },
{ name: 'Rowing' },
{ name: 'Boating' }
]
});
With this method we can map arrays against a modification getter.
const people = [john, michael, mary]
const upperCaseNamePeople = lens(people)
.map(({ name, ...person }) => ({
...person,
name: name.toUpperCase(),
}))
.get()
assert.deepEqual(upperCaseNamePeople, [
{ name: 'JOHN WALLACE' },
{ name: 'MICHAEL COLLINS' },
{ name: 'MARY SANCHEZ' },
])
Often times we want to work with a given array item property, for this we use traversal lenses which can be created this way.
const peopleNames = lens(people).tap().name.get()
assert.deepEqual(peopleNames, [
'John Wallace',
'Michael Collins',
'Mary Sanchez',
])
const peopleNamesStartingWithM = lens(people)
.tap(({ name }) => name[0] === 'M')
.name.get()
assert.deepEqual(peopleNamesStartingWithM, ['Michael Collins', 'Mary Sanchez'])
const surnameFirstPeople = lens(people)
.tap()
.name.map((name: string) => name.split(' ').reverse().join(', '))
.get()
assert.deepEqual(surnameFirstPeople, [
{ name: 'Wallace, John' },
{ name: 'Collins, Michael' },
{ name: 'Sanchez, Mary' },
])
We can also use the lens methods in an abstract way, so we can pass it to higher order functions:
const allCompanies = [
localizedEmployedJohn,
localizedEmployedMary,
employedMichael
].map(lens<Person>().company.name.get);
assert.deepEqual(allCompanies, ['Apple', 'Microsoft', 'Google']);
This library supports recursive types, that means we can work with generic data structures like Json
.
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
const jsonLens = lens<Json>()
assert.deepEqual(
jsonLens.name
.let('Jason Finch')
.hobbies[0].let({ name: 'Electronics' })
.company.name.let('Toshiba')
.company.address.set({
street: 'Shibaura 1-chome, 1-1',
city: 'Minato-ku',
zip: '105-8001',
}),
{
name: 'Jason Finch',
hobbies: [
{
name: 'Electronics',
},
],
company: {
name: 'Toshiba',
address: {
street: 'Shibaura 1-chome, 1-1',
city: 'Minato-ku',
zip: '105-8001',
},
},
},
)