npm install optix
Optix is a data manipulation library that can focus on one or many elements in a nested structure to get or set their values. Optix features robust Typescript support and is smaller and faster than true optics libraries.
- Simple yet powerful: optics-like capabilities with a simple, intuitive syntax
- Type-safe: Robust type checking with minimal type annotations
- Tiny: < 1kb gzipped, zero dependencies
Let's say we have the following data structure
import { get, set, find, filter, remove, all } from 'optix'
const state1 = {
title: 'Introduction',
steps: [
{ title: 'Introduce get', completed: false },
{ title: 'Introduce set', completed: false },
{ title: 'Introduce find', completed: false },
{ title: 'Introduce filter', completed: false },
{ title: 'Introduce remove', completed: false },
{ title: 'Introduce all', completed: false },
]
}
We can focus on the title of the first step and get
its value
get('steps', 0, 'title')(state1)
// 'Introduce get'
We can focus on the completed key of the first two steps and set
them both to true
const state2 = set('steps', [0, 1], 'completed')(true)(state1)
// {
// title: 'Introduction',
// steps: [
// { title: 'Introduce get', completed: true },
// { title: 'Introduce set', completed: true },
// { title: 'Introduce find', completed: false },
// { title: 'Introduce filter', completed: false },
// { title: 'Introduce remove', completed: false },
// { title: 'Introduce all', completed: false },
// ]
// }
We can find
the index of a step to focus on and set
it to be completed
const state3 = set('steps', find(step => step.title === 'Introduce find'), 'completed')(true)(state2)
// {
// title: 'Introduction',
// steps: [
// { title: 'Introduce get', completed: true },
// { title: 'Introduce set', completed: true },
// { title: 'Introduce find', completed: true },
// { title: 'Introduce filter', completed: false },
// { title: 'Introduce remove', completed: false },
// { title: 'Introduce all', completed: false },
// ]
// }
We can focus on the incomplete steps with a filter
and get
their titles
get('steps', filter(step => !step.completed), 'title')
// ['Introduce filter', 'Introduce remove', 'Introduce all']
We can focus on all
the steps and remove
their completed keys
const state4 = remove('steps', all, 'completed')(state3)
// {
// title: 'Introduction',
// steps: [
// { title: 'Introduce get' },
// { title: 'Introduce set' },
// { title: 'Introduce find' },
// { title: 'Introduce filter' },
// { title: 'Introduce remove' },
// { title: 'Introduce all' },
// ]
// }
We can even find and filter items in maps/records
import { get, findByVal, filterByVal } from 'optix'
const state = {
users: {
alice: { name: 'Alice', age: 22 },
bob: { name: 'Bob', age: 33 },
claire: { name: 'Claire', age: 44 },
}
}
get('users', findByVal(user => user.name.startsWith('C')))(state)
// { name: 'Claire', age: 44 }
get('users', filterByVal(user => user.age < 40))(state)
// [{ name: 'Alice', age: 22 }, { name: 'Bob', age: 33 }]
The main functions all take any number of PathItems to form a Path. Each PathItem can focus on one or many elements in an object or array.
type GetKey = (map: { [key: string]: any }) => string | undefined
type GetKeys = (map: { [key: string]: any }) => string[]
type GetIndex = (arr: any[]) => number | undefined
type GetIndexes = (arr: any[]) => number[]
type PathItem = string | number | string[] | number[] | GetKey | GetIndex | GetKeys | GetIndexes
type Path = PathItem[]
All updates are performed immutably
get
path => object => valueAtPath
get('foo', 'bar')({ foo: { bar: 'baz' } })
// 'baz'
get('letters', [0, 1])({ letters: ['a', 'b', 'c'] })
// ['a', 'b']
get('letters', arr => arr.length - 1)({ letters: ['a', 'b', 'c'] })
// 'c'
set
path => newValueAtPath => object => updatedObject
set('foo', 'bar')('BAZ')({ foo: { bar: 'baz' } })
// { foo: { bar: 'BAZ' } }
set('letters', [0, 1])('z')({ letters: ['a', 'b', 'c'] })
// { letters: ['z', 'z', 'c'] }
set('letters', arr => arr.length - 1)('z')({ letters: ['a', 'b', 'c'] })
// { letters: ['a', 'b', 'z'] }
update
path => updaterAtPath => object => updatedObject
const toUpper = str => str.toUpperCase()
update('foo', 'bar')(toUpper)({ foo: { bar: 'baz' } })
// { foo: { bar: 'BAZ' } }
update('letters', [0, 1])(toUpper)({ letters: ['a', 'b', 'c'] })
// { letters: ['A', 'B', 'c'] }
update('letters', arr => arr.length - 1)(toUpper)({ letters: ['a', 'b', 'c'] })
// { letters: ['a', 'b', 'C'] }
remove
path => object => updatedObject
remove('foo', 'bar')({ foo: { bar: 'baz' } })
// { foo: {} }
remove('letters', [0, 1])({ letters: ['a', 'b', 'c'] })
// { letters: ['c'] }
remove('letters', arr => arr.length - 1)({ letters: ['a', 'b', 'c'] })
// { letters: ['a', 'b'] }
Optix provides helper functions that can be used within paths to find or filter items in an array
import { get, all, filter, find, last } from 'optix'
const arrayHelpers = [
{ name: 'all', type: 'traversal' },
{ name: 'filter', type: 'prism' },
{ name: 'find', type: 'lens' },
{ name: 'last', type: 'lens' },
]
all - focus on all items in the array
array => index
get(all, 'name')(arrayHelpers)
// ['all', 'filter', 'find', 'last']
filter - focus on all items that match the predicate
predicate => array => indexes
get(filter(helper => helper.type === 'prism'), 'name')(arrayHelpers)
// ['filter']
find - focus on the first item that matches the predicate
predicate => array => index
get(find(helper => helper.type === 'lens'), 'name')(arrayHelpers)
// 'find'
last - focus on the last item in the array
array => index
get(last, 'name')(arrayHelpers)
// 'last'
Optix provides helper functions that can be used within paths to find or filter items in a record/map
import { get, filterByKey, filterByVal, findByKey, findByVal, keys } from 'optix'
const recordHelpers = {
filterByKey: { description: 'Filter By Key', type: 'prism', predicate: 'key' },
filterByVal: { description: 'Filter By Value', type: 'prism', predicate: 'value' },
findByKey: { description: 'Find By Key', type: 'lens', predicate: 'key' },
findByVal: { description: 'Find By Value', type: 'lens', predicate: 'value' },
keys: { description: 'All Keys', type: 'traversal' },
}
filterByKey - focus on all items that match the predicate
predicate => record => keys
get(filterByKey(key => key.startsWith('filterBy')), 'description')(recordHelpers)
// ['Filter By Key', 'Filter By Value']
filterByVal - focus on all items that match the predicate
predicate => record => keys
get(filterByVal(val => val.type === 'prism'), 'description')(recordHelpers)
// ['Filter By Key', 'Filter By Value']
findByKey - focus on the first item that matches the predicate
predicate => record => key
get(findByKey(key => key.startsWith('findBy')), 'description')(recordHelpers)
// 'Find By Key'
findByVal - focus on the first item that matches the predicate
predicate => record => key
get(findByVal(val => val.type === 'lens' && val.predicate === 'value'), 'description')(recordHelpers)
// 'Find By Value'
keys - focus on all items in the object
record => key
get(keys, 'description')(recordHelpers)
// ['Filter By Key', 'Filter By Value', 'Find By Key', 'Find By Value', 'All Keys']
Custom query helpers are easy to make, they just need to return one or more keys or indexes. Lodash's findIndex and findKey can be used as drop-in replacements for find
and findByVal
respectively.
Keys must be strings or non-negative numbers. Operations will halted if an invalid key is found.
// find will return -1 since Dave does not exist in the array
// a shallow copy of the array will be returned, no element will be modified
set(find(person => person.name === 'Dave'), 'name')('David')([{ name: 'Alice' }, { name: 'Bob' }])
// [{ name: 'Alice' }, { name: 'Bob' }]
get
does not require any typings
const getFooCompleted = get('foo', 'completed') // expect type { foo: { completed: unknown } }
const bool = getFooCompleted({ foo: { completed: false } }) // return type is boolean
// false
set
does not require any typings
if the new value is missing keys the return type will be unknown
const setFooCompleted = set('foo', 'completed')(true) // expect type { foo: { completed: boolean } }
const completedFoo = setFooCompleted({ foo: { completed: false } }) // return type same as final argument type
// { foo: { completed: true } }
update
requires the updater to be typed
if the new value is missing keys the return type will be unknown
const obj = { foo: { completed: false } }
const toggleFooCompleted1 = update('foo', 'completed')((bool: boolean) => !bool) // expect type { foo: { completed: boolean } }
const toggleFooCompleted2 = update('foo', 'completed')<boolean>((bool) => !bool) // same as above
const completedFoo = toggleFooCompleted1({ foo: { completed: false } }) // return type same as final argument type
// { foo: { completed: true } }
remove
does not require explicit typings
if the path targets a required key the return type will be unknown
const foo: { foo: { completed?: boolean } } = { foo: { completed: false } }
const removeFooCompleted = remove('foo', 'completed') // expect type { foo: { completed: unknown } }
const fooWithoutCompleted = removeFooCompleted(foo) // return type same as final argument type
// { foo: {} }
Polymorphism
Optix provides aliased versions of the main functions with typings that support polymorphism
import { polySet, polyUpdate, polyRemove } from 'optix'
// changing boolean to string
polySet('foo')('bar')({ foo: true }) // return type { foo: string }
// { foo: 'bar' }
// removing required key
polyRemove('foo')({ foo: true, bar: true, baz: true }) // return type { bar: boolean; baz: boolean }
// { bar: true, baz: true }
Query Helpers
The find*
and filter*
helpers can be typed with either a type argument or by typing the callback
interface User {
name: string
id: string
}
interface State {
list: User[]
map: { [id: string]: User }
}
const state: State = {
list: [{ name: 'Alice', id: 'A123' }, { name: 'Bob', id: 'B234' }, { name: 'Claire', id: 'C345' }],
map: {
A123: { name: 'Alice', id: 'A123' },
B234: { name: 'Bob', id: 'B234' },
C345: { name: 'Claire', id: 'C345' }
}
}
get('list', find<typeof State.list>((user) => user.name === 'Alice'))(state)
get('list', find((user: User) => user.name === 'Alice'))(state)
get('map', findByVal<typeof State.map>((user) => user.name === 'Alice'))(state)
get('map', findByVal((user: User) => user.name === 'Alice'))(state)
If you want a data-first, lodash-like syntax there's a set of functions just for you!
these functions do not have full TypeScript support
import { _get, _set, _update, _delete } from 'optix'
_get({ foo: { bar: 'baz' } }, ['foo', 'bar'])
// 'baz'
_set({ foo: { bar: 'baz' } }, ['foo', 'bar'], 'BAZ')
// { foo: { bar: 'BAZ' } }