Skip to content

Commit

Permalink
feat(combinators): add sequence, firstOf, never, always! (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
serras committed Sep 30, 2020
1 parent 497f92e commit d703c4f
Show file tree
Hide file tree
Showing 19 changed files with 320 additions and 25 deletions.
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ The different kinds of optics can be arranged into a hierarchy. Going up means w
## Know them all!

`optics.js` ships with a bunch of predefined optics. Bear in mind that the `alter` and `ix` lenses are the default when you use the `optic` generator, so:
`optics.js` ships with a bunch of predefined optics. Bear in mind that the `alter` and `ix` lenses are the default when you use combinators, so:

```js
optic(maybe('friends'), 0, 'name')
Expand Down Expand Up @@ -173,6 +173,35 @@ if (!preview(maybe('age'), person)) ...
maybe('age').preview(person) || defaultAge
```

### Combinators

#### `optic : [Optic a b, Optic b c, ..., Optic y z] -> Optic a z`

Creates a combined optic by applying each one on the result of the previous one. This is the most common way to combine optics.

#### `sequence : [Fold s a] -> Fold s a`

Joins the result of several optics into a single one. In other words, targets all values from each of the given optics.

```js
sequence('age', 'name').toArray({ name: 'Alex', age: 32 }) // [ 32, 'Alex' ]
```

#### `firstOf : [Optic s a] -> Optic s a`

Tries each of the optics until one matches, that is, returns something different from `notFound` (when talking about optionals or partial getters) and `[]` (when talking about traversals and folds).

```js
view(firstOf('firstName', 'name'), { name: 'Alex', age: 32 }) // 'Alex'
```

In combination with `always` it can be used to provide a default value when an optic targets no elements.

```js
view(firstOf('name', always('Pepe')), { name: 'Alex', age: 32 }) // 'Alex'
view(firstOf('name', always('Pepe')), { }}) // 'Pepe'
```

### Lenses (view, set)

#### `alter : k -> Lens (Object | notFound) (a | notFound)`
Expand Down Expand Up @@ -216,6 +245,10 @@ over(maybe('age'), (x) => x + 1, { name: 'Flavio' })
// { name: 'Flavio' }
```

#### `never : Optional s a`

This optional _never_ matches: `view`ing through it always returns `notFound`, using it to set makes no changes to the given value. It can be useful when combined in `sequence`, as it adds no additional values.

### Prisms (preview, set, review)

#### `has : { ...obj } -> Prism { ...obj, ...rest } { ...obj }`
Expand Down Expand Up @@ -279,6 +312,16 @@ over(entries, ([k, v]) => [k, v + 1], numbers) // right
over(entries, ([k, v]) => v + 1, numbers) // throws TypeError
```

### Getters (view)

#### `always : a -> Getter s a`

Always return the given value. As described above, it can be used in combination with `firstOf` to provide a default value for a possibly-missing field.

```js
view(always('zoo'), 3) // 'zoo'
```

### Isos (view, set, review)

#### `single : k -> Iso { k: a } a`
Expand Down
19 changes: 17 additions & 2 deletions __tests__/Fold.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { foldFromToArray } from '../src/Fold'
import { foldFromReduce, foldFromToArray } from '../src/Fold'
import { optic, reduce, toArray } from '../src/operations'

const doubleArray = [
Expand All @@ -7,6 +7,7 @@ const doubleArray = [
]

const valuesFold = foldFromToArray((obj) => [...obj])
const valuesReduceFold = foldFromReduce((f, i, obj) => obj.reduce(f, i))

describe('Traversal', () => {
test('traversal from array reduces', () => {
Expand All @@ -18,8 +19,22 @@ describe('Traversal', () => {
expect(reduce(o, (x, y) => x + y, 0, doubleArray)).toBe(10)
})

test('traversal from double array reduces', () => {
test('traversal from double array turns into array', () => {
const o = optic(valuesFold, valuesFold)
expect(toArray(o, doubleArray)).toStrictEqual(doubleArray.flat())
})

test('traversal from reduce reduces', () => {
expect(reduce(valuesReduceFold, (x, y) => x + y, 0, [1, 2])).toBe(3)
})

test('traversal from double reduce reduces', () => {
const o = optic(valuesReduceFold, valuesReduceFold)
expect(reduce(o, (x, y) => x + y, 0, doubleArray)).toBe(10)
})

test('traversal from double reduce turns into array', () => {
const o = optic(valuesReduceFold, valuesReduceFold)
expect(toArray(o, doubleArray)).toStrictEqual(doubleArray.flat())
})
})
8 changes: 6 additions & 2 deletions __tests__/Getter.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { get } from '../src/functions'
import { getter } from '../src/Getter'
import { preview, toArray } from '../src/operations'
import { always, getter } from '../src/Getter'
import { preview, toArray, view } from '../src/operations'

const obj = {
foo: [1, 2, 3],
Expand All @@ -23,4 +23,8 @@ describe('Getter', () => {
expect(toArray(partial, obj)).toEqual(['baz'])
expect(partial.toArray(obj)).toEqual(['baz'])
})

test('always works as expected', () => {
expect(view(always('foo'), obj)).toBe('foo')
})
})
15 changes: 14 additions & 1 deletion __tests__/Iso.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { UnavailableOpticOperationError } from '../src/errors'
import { toUpper } from '../src/functions'
import { iso, single } from '../src/Iso'
import { optic, over, preview, reduce, review, set, toArray, view } from '../src/operations'
import {
matches,
optic,
over,
preview,
reduce,
review,
set,
toArray,
view,
} from '../src/operations'

const id = (x) => x
const idIso = iso(id, id)
Expand Down Expand Up @@ -60,6 +70,8 @@ describe('Iso', () => {
expect(toArray(o, { name: 'Alex' })).toStrictEqual(['Alex'])
expect(o.toArray({ name: 'Alex' })).toStrictEqual(['Alex'])
expect(reduce(o, (x, y) => x + y, 1, { name: 2 })).toBe(3)
expect(matches(o, { name: 'Alex' })).toBe(true)
expect(o.matches({ name: 'Alex' })).toBe(true)

if (gopt === 'Fold') {
expect(() => preview(o, { name: 'Alex' })).toThrow(UnavailableOpticOperationError)
Expand Down Expand Up @@ -100,6 +112,7 @@ describe('Iso', () => {
expect(() => toArray(o, { name: 'Alex' })).toThrow(UnavailableOpticOperationError)
expect(() => set(o, 'Flavio', { name: 'Alex' })).toThrow(UnavailableOpticOperationError)
expect(() => over(o, toUpper, { name: 'Alex' })).toThrow(UnavailableOpticOperationError)
expect(() => matches(o, { name: 'Alex' })).toThrow(UnavailableOpticOperationError)
}
})
})
Expand Down
7 changes: 6 additions & 1 deletion __tests__/Lens.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { get, set as assoc, toUpper } from '../src/functions'
import { alter, ix, lens, mustBePresent } from '../src/Lens'
import { notFound } from '../src/notFound'
import { optic, over, preview, set, toArray, view } from '../src/operations'
import { matches, optic, over, preview, sequence, set, toArray, view } from '../src/operations'

const friends = ['Alejandro', 'Pepe']
const user = { id: 1, name: 'Flavio' }
Expand All @@ -18,6 +18,7 @@ describe('Lens', () => {
expect(lense.get(user)).toBe('Flavio')
expect(preview(lense, user)).toBe('Flavio')
expect(set(lense, 'Alejandro', user)).toEqual(alex)
expect(matches(lense, user)).toBe(true)
})

test('mustBePresent should build a lens', () => {
Expand Down Expand Up @@ -151,4 +152,8 @@ describe('Lens', () => {
expect(toArray(nameOptional, user)).toEqual(['Flavio'])
expect(nameOptional.toArray(user)).toEqual(['Flavio'])
})

test('should sequence lenses correctly', () => {
expect(toArray(sequence('id', 'name'), user)).toEqual([1, 'Flavio'])
})
})
90 changes: 87 additions & 3 deletions __tests__/Optional.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { OpticCreationError } from '../src/errors'
import { OpticComposeError, OpticCreationError } from '../src/errors'
import { get, set as assoc, toUpper } from '../src/functions'
import { alter } from '../src/Lens'
import { notFound } from '../src/notFound'
import { optic, over, preview, set, toArray } from '../src/operations'
import { maybe, optional, optionalIx, optionalProp } from '../src/Optional'
import { firstOf, matches, optic, over, preview, set, toArray } from '../src/operations'
import { maybe, never, optional, optionalIx, optionalProp } from '../src/Optional'

const friends = ['Alejandro']
const user = { id: 1, name: 'Flavio' }
Expand All @@ -22,6 +22,7 @@ describe('Optional', () => {
test('optionalProp should build an optional', () => {
const nameL = optionalProp('name')
expect(preview(nameL, user)).toBe('Flavio')
expect(matches(nameL, user)).toBe(true)
})

test('optionalProp should works as a traversal', () => {
Expand All @@ -33,6 +34,7 @@ describe('Optional', () => {
test('optionalIndex should build an optional', () => {
const idx0 = optionalIx(0)
expect(preview(idx0, friends)).toBe('Alejandro')
expect(matches(idx0, friends)).toBe(true)
expect(set(idx0, 'Alex', friends)).toStrictEqual(['Alex'])
})

Expand All @@ -58,13 +60,17 @@ describe('Optional', () => {

test('optionals with non-existing key should return notFound', () => {
const serventesioL = optionalProp('serventesio')
expect(set(serventesioL, 'Pi', user)).toStrictEqual(user)
expect(serventesioL.set('Pi', user)).toStrictEqual(user)
expect(preview(serventesioL, user)).toBe(notFound)
expect(matches(serventesioL, user)).toBe(false)
})

test('optionals with non-existing as traversal should return []', () => {
const serventesioL = optionalProp('serventesio')
expect(toArray(serventesioL, user)).toStrictEqual([])
expect(toArray(serventesioL.asTraversal, user)).toStrictEqual([])
expect(matches(serventesioL.asTraversal, user)).toBe(false)
})

test('optionals with non-existing key should not change the value', () => {
Expand All @@ -74,10 +80,88 @@ describe('Optional', () => {

test('optionals with one non-existing key should return notFound', () => {
const firstFriendL = optic(optionalProp('friends'), optionalIx(1000))
expect(set(firstFriendL, 'Pi', userWithFriends)).toStrictEqual(userWithFriends)
expect(firstFriendL.set('Pi', userWithFriends)).toStrictEqual(userWithFriends)
expect(preview(firstFriendL, userWithFriends)).toBe(notFound)
expect(matches(firstFriendL, userWithFriends)).toBe(false)
})

test('maybe should fail for wrong types', () => {
expect(() => maybe([1, 2])).toThrow(OpticCreationError)
})

test('never should always return notFound', () => {
expect(matches(never, user)).toBe(false)
expect(never.matches(user)).toBe(false)
expect(matches(never, [1, 2])).toBe(false)

expect(set(never, 'A', user)).toStrictEqual(user)
expect(never.set('A', user)).toStrictEqual(user)
expect(set(never, 'A', [1, 2])).toStrictEqual([1, 2])
expect(never.set('A', [1, 2])).toStrictEqual([1, 2])
})

test('first of works for viewing', () => {
expect(preview(firstOf('name', 'toli'), user)).toBe('Flavio')
expect(preview(firstOf('toli', 'name'), user)).toBe('Flavio')
expect(preview(firstOf('name', 'id'), user)).toBe('Flavio')
expect(preview(firstOf('id', 'name'), user)).toBe(1)
expect(preview(firstOf('toli', 'moli'), user)).toBe(notFound)
})

test('first of works for viewing as traversals', () => {
const nameL = optionalProp('name').asTraversal
const idL = optionalProp('id').asTraversal
const toliL = optionalProp('toli').asTraversal
const moliL = optionalProp('moli').asTraversal
expect(toArray(firstOf(nameL, toliL), user)).toStrictEqual(['Flavio'])
expect(toArray(firstOf(toliL, nameL), user)).toStrictEqual(['Flavio'])
expect(toArray(firstOf(nameL, idL), user)).toStrictEqual(['Flavio'])
expect(toArray(firstOf(idL, nameL), user)).toStrictEqual([1])
expect(toArray(firstOf(toliL, moliL), user)).toStrictEqual([])
})

test('first of works for setting', () => {
expect(over(firstOf('name', 'toli'), toUpper, user)).toStrictEqual({ id: 1, name: 'FLAVIO' })
expect(over(firstOf('toli', 'name'), toUpper, user)).toStrictEqual({ id: 1, name: 'FLAVIO' })
expect(over(firstOf('name', 'id'), toUpper, user)).toStrictEqual({ id: 1, name: 'FLAVIO' })
expect(over(firstOf('id', 'name'), (x) => x + 1, user)).toStrictEqual({ id: 2, name: 'Flavio' })
expect(over(firstOf('toli', 'moli'), toUpper, user)).toStrictEqual(user)
expect(firstOf('toli', 'moli').set('chorizo', user)).toStrictEqual(user)
})

test('first of works for modifying as traversals', () => {
const nameL = optionalProp('name').asTraversal
const idL = optionalProp('id').asTraversal
const toliL = optionalProp('toli').asTraversal
const moliL = optionalProp('moli').asTraversal
expect(over(firstOf(nameL, toliL), toUpper, user)).toStrictEqual({ id: 1, name: 'FLAVIO' })
expect(over(firstOf(toliL, nameL), toUpper, user)).toStrictEqual({ id: 1, name: 'FLAVIO' })
expect(over(firstOf(nameL, idL), toUpper, user)).toStrictEqual({ id: 1, name: 'FLAVIO' })
expect(over(firstOf(idL, nameL), (x) => x + 1, user)).toStrictEqual({ id: 2, name: 'Flavio' })
expect(over(firstOf(toliL, moliL), toUpper, user)).toStrictEqual(user)
})

test('first of works for viewing as everything', () => {
const nameL = optionalProp('name')
const toliL = optionalProp('toli')
expect(toArray(firstOf(nameL.asTraversal, toliL.asTraversal), user)).toStrictEqual(['Flavio'])
expect(toArray(firstOf(nameL.asPartialGetter, toliL.asPartialGetter), user)).toStrictEqual([
'Flavio',
])
expect(toArray(firstOf(nameL.asFold, toliL.asFold), user)).toStrictEqual(['Flavio'])
})

test('first of works for modifying as everything', () => {
const nameL = optionalProp('name')
const toliL = optionalProp('toli')
expect(over(firstOf(nameL.asTraversal, toliL.asTraversal), toUpper, user)).toStrictEqual({
id: 1,
name: 'FLAVIO',
})
// cannot compose setters this way
expect(() => over(firstOf(nameL.asSetter, toliL.asSetter), toUpper, user)).toThrow(
OpticComposeError,
)
})
})
17 changes: 16 additions & 1 deletion __tests__/Prism.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OpticComposeError, UnavailableOpticOperationError } from '../src/errors'
import { toUpper } from '../src/functions'
import { notFound } from '../src/notFound'
import { optic, over, preview, review, toArray } from '../src/operations'
import { matches, optic, over, preview, review, sequence, toArray } from '../src/operations'
import { has } from '../src/Prism'

const user = { id: 1, name: 'Flavio' }
Expand All @@ -10,17 +11,31 @@ const modifyUser = (u) => ({ ...u, name: 'Alejandro' })
describe('Prism', () => {
test('has returns itself if ok', () => {
expect(preview(has({ id: 1 }), user)).toEqual(user)
expect(matches(has({ id: 1 }), user)).toBe(true)
expect(() => matches(has({ id: 1 }).asReviewer, user)).toThrow(UnavailableOpticOperationError)
expect(() => sequence(has({ id: 1 }).asReviewer)).toThrow(OpticComposeError)
})

test('has returns nothing if not found', () => {
expect(preview(has({ id: 2 }), user)).toEqual(notFound)
expect(matches(has({ id: 2 }), user)).toBe(false)
})

test('has works correctly when setting', () => {
expect(over(has({ id: 1 }), modifyUser, user)).toStrictEqual(modifiedUser)
expect(has({ id: 1 }).over(modifyUser, user)).toStrictEqual(modifiedUser)
})

test('has sets nothing if not found', () => {
expect(over(has({ id: 2 }), modifyUser, user)).toStrictEqual(user)
expect(has({ id: 2 }).over(modifyUser, user)).toStrictEqual(user)
})

test('has works in review', () => {
expect(review(has({ id: 1 }), { name: 'Flavio' })).toStrictEqual(user)
expect(has({ id: 1 }).review({ name: 'Flavio' })).toStrictEqual(user)
})

test('has works correctly in composition with itself', () => {
expect(optic(has({ id: 1 }), has({ name: 'Flavio' })).preview(user)).toStrictEqual(user)
expect(optic(has({ id: 1 }), has({ name: 'Flavio' })).preview({ id: 2, name: 'Flavio' })).toBe(
Expand Down

0 comments on commit d703c4f

Please sign in to comment.