diff --git a/.github/README.md b/.github/README.md
index 6234f21..966cbb8 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -15,6 +15,7 @@ A type safe functional lens implemented via proxy
- [Getter](#gettera-b-type)
- [Setter](#settera-b-type)
- [ProxyLens](#proxylensa-b-type)
+ - [ProxyTraversal](#proxytraversala-b-type)
- [BaseLens](#baselensa-b-interface)
+ [.get(a?: A): B](#geta-a-b)
+ [.set(b: B, a?: A): A](#setb-b-a-a-a)
@@ -25,6 +26,8 @@ A type safe functional lens implemented via proxy
- [ArrayLens](#arraylensa-b-interface)
+ [.del(index: number, a?: A): ProxyLens](#delindex-number-a-a-proxylensa-a)
+ [.put(index: number, b: B | B[], a?: A): ProxyLens](#putindex-number-b-b--b-a-a-proxylensa-a)
+ + [.map(get: Getter): ProxyLens](#mapget-getterb-b-proxylensa-a)
+ + [.tap(get?: Getter): ProxyTraversal](#tapget-getterb-boolean-proxytraversala-b)
* [Usage](#usage)
- [Setup](#setup)
- [Setting values with the `.set` method](#setting-values-with-the-set-method)
@@ -33,6 +36,8 @@ A type safe functional lens implemented via proxy
- [Pegging lenses with the `.peg` method](#pegging-lenses-with-the-peg-method)
- [Modifying lenses with the `.mod` method](#modifying-lenses-with-the-mod-method)
- [Manipulating immutable arrays with the `.del` and `.put` methods](#manipulating-immutable-arrays-with-the-del-and-put-methods)
+ - [Modifying array items with the `.map` method](#modifying-array-items-with-the-map-method)
+ - [Traversing arrays using the `.tap` method](#traversing-arrays-using-the-tap-method)
- [Using abstract lenses](#using-abstract-lenses)
- [Recursive abstract lenses](#recursive-abstract-lenses)
@@ -107,6 +112,14 @@ A union between the interfaces of `BaseLens` and `ArrayLens`.
---
+### `ProxyTraversal` (type)
+
+A special kind of [`ProxyLens`](#proxylensa-b-type) used to represent traversals.
+
+[Back to top ↑](#proxy-lens)
+
+---
+
### `BaseLens` (interface)
```typescript
@@ -315,6 +328,68 @@ lens<{ a: string[] }>()
---
+#### `.map(get: Getter): ProxyLens`
+
+It works like the [`.mod`](#modget-getterb-b-proxylensa-a) method but for array items.
+
+```typescript
+lens({ a: ['map'] })
+ .a.map((str) => str.toUpperCase()).get() // :: { a: ['MAP'] }
+
+lens<{ a: string[] }>()
+ .a.map((str) => str.toUpperCase())
+ .get({ a: ['map'] }) // :: { a: ['MAP'] }
+```
+
+[Back to top ↑](#proxy-lens)
+
+---
+
+#### `.tap(get?: Getter): ProxyTraversal`
+
+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.
+
+```typescript
+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' } ] }
+```
+
+[Back to top ↑](#proxy-lens)
+
+---
+
## Usage
Here's a throughout usage example.
@@ -582,6 +657,67 @@ assert.deepEqual(sailorMary, {
---
+### Modifying array items with the `.map` method
+
+With this method we can map arrays against a modification getter.
+
+```typescript
+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' },
+])
+```
+
+
+[Back to top ↑](#proxy-lens)
+
+---
+
+### Traversing arrays using the `.tap` method
+
+Often times we want to work with a given array item property, for this we use traversal lenses which can be created this way.
+
+```typescript
+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' },
+])
+```
+
+[Back to top ↑](#proxy-lens)
+
+---
+
### Using abstract lenses
We can also use the lens methods in an abstract way, so we can pass it to higher order functions:
diff --git a/package.json b/package.json
index 26483b5..dddd96e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "proxy-lens",
- "version": "1.3.1",
+ "version": "1.4.0",
"description": "A type safe functional lens implemented via proxy",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -8,6 +8,7 @@
"lint": "eslint './src/**/*.ts'",
"test": "jest",
"test:watch": "jest --watchAll",
+ "typecheck": "tsc --project tsconfig.json --pretty --noEmit && true",
"build": "tsc",
"watch": "tsc -w"
},
diff --git a/src/example/index.ts b/src/example/index.ts
index 25c64e4..b5243bd 100644
--- a/src/example/index.ts
+++ b/src/example/index.ts
@@ -177,6 +177,50 @@ assert.deepEqual(sailorMary, {
],
})
+// Modifying array items with the `.map()` method
+
+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' },
+])
+
+// Traversing arrays using the `.tap()` method
+
+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' },
+])
+
// Using abstract lenses
const allCompanies = [
diff --git a/src/index.test.ts b/src/index.test.ts
index adb601a..7ef847f 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -787,3 +787,161 @@ describe('put', () => {
})
})
})
+
+describe('map', () => {
+ it('maps an array', () => {
+ const source = { a: { b: [{ c: 'map' }] } }
+ expect(
+ lens(source)
+ .a.b.map(({ c }) => ({ c: c.toUpperCase() }))
+ .get(),
+ ).toMatchObject({
+ a: { b: [{ c: 'MAP' }] },
+ })
+ })
+
+ it('maps an array via null to empty array', () => {
+ const source: { a: { b: { c: string }[] | null } | null } = {
+ a: null,
+ }
+ expect(
+ lens(source)
+ .a.b.map((it) => it)
+ .get(),
+ ).toMatchObject({
+ a: { b: [] },
+ })
+ })
+
+ it('maps an array via undefined to empty array', () => {
+ const source: { a?: { b?: { c?: string }[] } } = {}
+ expect(
+ lens(source)
+ .a.b.map((it) => it)
+ .get(),
+ ).toMatchObject({
+ a: { b: [] },
+ })
+ })
+
+ it('maps an array using abstract lens', () => {
+ const source = {
+ a: { b: [{ c: 'map' }] },
+ }
+ expect(
+ lens<{ a: { b: { c: string }[] } }>()
+ .a.b.map(({ c }) => ({ c: c.toUpperCase() }))
+ .get(source),
+ ).toMatchObject({
+ a: { b: [{ c: 'MAP' }] },
+ })
+ })
+
+ it('sets a mapped array', () => {
+ const source = { a: { b: [{ c: 'map' }] } }
+ expect(
+ lens(source)
+ .a.b.map(({ c }) => ({ c: c.toUpperCase() }))
+ .a.b.set([{ c: 'set' }]),
+ ).toMatchObject({
+ a: { b: [{ c: 'SET' }] },
+ })
+ })
+
+ it('sets a mapped array via null to empty array', () => {
+ const source: { a: { b: { c: string }[] | null } | null } = {
+ a: null,
+ }
+ expect(
+ lens(source)
+ .a.b.map((it) => it)
+ .a.b.set([{ c: 'set' }]),
+ ).toMatchObject({
+ a: { b: [{ c: 'set' }] },
+ })
+ })
+
+ it('sets a mapped array via undefined to empty array', () => {
+ const source: { a?: { b?: { c?: string }[] } } = {}
+ expect(
+ lens(source)
+ .a.b.map((it) => it)
+ .a.b.set([{ c: 'set' }]),
+ ).toMatchObject({
+ a: { b: [{ c: 'set' }] },
+ })
+ })
+})
+
+describe('tap', () => {
+ it('taps an array', () => {
+ const source = { a: { b: [{ c: 'traversal' }, { c: 'map' }] } }
+ expect(lens(source).a.b.tap().c.get()).toMatchObject(['traversal', 'map'])
+ })
+
+ it('taps an array with a filter', () => {
+ const source = { a: { b: [{ c: 'noget' }, { c: 'tap' }] } }
+ expect(
+ lens(source)
+ .a.b.tap(({ c }) => c === 'tap')
+ .c.get(),
+ ).toMatchObject(['tap'])
+ })
+
+ it('taps an array via null', () => {
+ const source: { a: { b: { c: string }[] | null } | null } = { a: null }
+ expect(lens(source).a.b.tap().c.get()).toMatchObject([])
+ })
+
+ it('taps an array via undefined', () => {
+ const source: { a?: { b?: { c?: string }[] } } = {}
+ expect(lens(source).a.b.tap().c.get()).toMatchObject([])
+ })
+
+ it('taps an array usgin abstract lens', () => {
+ const source = {
+ a: { b: [{ c: 'traversal' }, { c: 'map' }] },
+ }
+ expect(
+ lens<{ a: { b: { c: string }[] } }>().a.b.tap().c.get(source),
+ ).toMatchObject(['traversal', 'map'])
+ })
+
+ it('sets a tapped array', () => {
+ const source = {
+ a: { b: [{ c: 'noset' }, { c: 'tap' }, { c: 'tap' }] },
+ }
+ expect(
+ lens(source)
+ .a.b.tap(({ c }) => c === 'tap')
+ .c.set(['set']),
+ ).toMatchObject({
+ a: { b: [{ c: 'noset' }, { c: 'set' }, { c: 'tap' }] },
+ })
+ })
+
+ it('sets a tapped array via null', () => {
+ const source: { a: { b: { c: string }[] | null } | null } = { a: null }
+ expect(lens(source).a.b.tap().c.set(['set', 'set'])).toMatchObject({
+ a: { b: [] },
+ })
+ })
+
+ it('sets a tapped array via undefined', () => {
+ const source: { a?: { b?: { c?: string }[] } } = {}
+ expect(lens(source).a.b.tap().c.set(['set', 'set'])).toMatchObject({
+ a: { b: [] },
+ })
+ })
+
+ it('sets a tapped array using abstract lens', () => {
+ const source = { a: { b: [{ c: 'tap' }, { c: 'tap' }] } }
+ expect(
+ lens<{ a: { b: { c: string }[] } }>()
+ .a.b.tap()
+ .c.set(['set', 'set'], source),
+ ).toMatchObject({
+ a: { b: [{ c: 'set' }, { c: 'set' }] },
+ })
+ })
+})
diff --git a/src/index.ts b/src/index.ts
index 1cc18b2..3841710 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -30,6 +30,14 @@ type ExtractArray = Extract>
export type ArrayLens> = {
del(index: number): ProxyLens
put(index: number, value: ArrayItem | B): ProxyLens
+ map(
+ get_:
+ | Getter, ArrayItem>
+ | ProxyLens, ArrayItem>,
+ ): ProxyLens
+ tap(
+ get_?: Getter, boolean> | ProxyLens, boolean>,
+ ): ProxyTraversal
}
type ArrayProxyLens> = ArrayLens &
@@ -42,6 +50,29 @@ export type ProxyLens = BaseProxyLens &
? unknown
: ArrayProxyLens>>)
+type BaseProxyTraversal = BaseLens &
+ (ExtractRecord extends { [key: string]: unknown }
+ ? {
+ [K in keyof ExtractRecord]-?: ProxyLens<
+ A,
+ ReadonlyArray[K]>
+ >
+ }
+ : unknown)
+
+type ArrayProxyTraversal> = ArrayLens &
+ {
+ [I in keyof Omit>]: ProxyLens<
+ A,
+ ReadonlyArray
+ >
+ }
+
+export type ProxyTraversal = BaseProxyTraversal> &
+ (ExtractArray extends never
+ ? unknown
+ : ArrayProxyTraversal>>>)
+
function baseLens(
get: Getter,
set: Setter,
@@ -115,6 +146,39 @@ function arrayLens>(
(value: A): A => value,
root,
),
+ map: (get_: Getter, ArrayItem>): ProxyLens =>
+ proxyLens(
+ (target: A): A => set((get(target) ?? []).map(get_), target),
+ (values: A): A => set(get(values).map(get_), values),
+ root,
+ ),
+ tap: (
+ get_?: Getter, boolean>,
+ ): ProxyTraversal> =>
+ proxyTraversal>(
+ (target: A): ArrayFrom =>
+ (get(target) ?? []).filter(get_ ?? ((_) => true)),
+ (values: ArrayFrom, target: A): A =>
+ set(
+ (get(target) ?? []).reduce<{
+ iterator: Iterator>
+ result: ArrayFrom
+ }>(
+ ({ iterator, result: value }, item) => ({
+ iterator,
+ result: [
+ ...value,
+ (get_ ?? ((_) => true))(item)
+ ? iterator.next().value ?? item
+ : item,
+ ],
+ }),
+ { iterator: values[Symbol.iterator](), result: [] },
+ ).result,
+ target,
+ ),
+ root,
+ ),
}
}
@@ -122,7 +186,25 @@ function getTarget(key: string | number | symbol) {
return (key.toString().match(/^\+?(0|[1-9]\d*)$/) ? [] : {}) as T
}
-export function proxyLens(
+function dispatch(
+ key: string | number | symbol,
+ get: Getter | Getter>,
+ set: Setter | Setter>,
+ root?: A,
+) {
+ return (
+ arrayLens>(
+ get as Getter>,
+ set as Setter>,
+ root,
+ )[key as keyof ArrayLens>] ??
+ baseLens(get as Getter, set as Setter, root)[
+ key as keyof BaseLens
+ ]
+ )
+}
+
+function proxyLens(
get: Getter | Getter>,
set: Setter | Setter>,
root?: A,
@@ -131,14 +213,7 @@ export function proxyLens(
apply: (_, __, args: (A | void)[]): B =>
(get as Getter)(args[0] as A),
get: (_, key) =>
- arrayLens(
- get as Getter>,
- set as Setter>,
- root,
- )[key as keyof ArrayLens>] ??
- baseLens(get as Getter, set as Setter, root)[
- key as keyof BaseLens
- ] ??
+ dispatch(key, get, set, root) ||
proxyLens(
(target: A): B[keyof B] =>
Object.assign(getTarget(key), get(target))[key as keyof B],
@@ -154,6 +229,56 @@ export function proxyLens(
})
}
+function proxyTraversal>(
+ get: Getter> | Getter>>,
+ set: Setter> | Setter>>,
+ root?: A,
+): ProxyTraversal> {
+ return new Proxy(get as ProxyTraversal>, {
+ get: (_, key) =>
+ dispatch(key, get, set, root) ||
+ proxyLens[keyof ArrayItem]>>(
+ (target: A): ReadonlyArray[keyof ArrayItem]> =>
+ get(target).map(
+ (item) =>
+ Object.assign(getTarget>(key), item)[
+ key as keyof ArrayItem
+ ],
+ ),
+ (
+ values: ReadonlyArray[keyof ArrayItem]>,
+ target: A,
+ ): A =>
+ set(
+ values.reduce<{
+ iterator: Iterator>
+ result: ArrayFrom
+ }>(
+ ({ iterator, result }, value) => ({
+ iterator,
+ result: [
+ ...result,
+ Object.assign(
+ getTarget>(key),
+ iterator.next().value,
+ {
+ [key as keyof ArrayItem]: value,
+ },
+ ),
+ ],
+ }),
+ {
+ iterator: get(target)[Symbol.iterator](),
+ result: [],
+ },
+ ).result,
+ target,
+ ),
+ root,
+ ),
+ })
+}
+
export function lens(root?: A): ProxyLens {
return proxyLens(
(target: A): A => target,