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,