Do you want to love immutable data but think it's a drag?
Are you perplexed by the syntax of immutability-helper? Repulsed by immer.js's use of assignment? Alarmed by lodash's lack of type safety?
Looking for something a little more intuitive, powerful & flexible? Clear up your data w/ spectacles-ts
(github repo)!
yarn add fp-ts spectacles-ts
Syntax (featuring auto-complete!)
import { pipe } from 'fp-ts/function'
import { set } from 'spectacles-ts'
const oldObj = { a: { b: 123 } }
const newObj = pipe(oldObj, set('a.b', 999))
// oldObj = { a: { b: 123 } }
// newObj = { a: { b: 999 } }
It's that simple!
(For more info on pipe
and fp-ts
, check out the appendix)
You can set a nullable value using a ?
, similar to optional chaining syntax in native js:
interface Obj { a?: { b: number } }
const obj: Obj = { a: { b: 123 } }
const obj2: Obj = {}
const x = pipe(obj, set('a?.b', 456))
const y = pipe(obj2, set('a?.b', 456))
// x = { a: { b: 456 } }
// y = {}
You can change at an index of a tuple:
const tup = [123, 'abc'] as const
const x = pipe(tup, set('[0]', 456))
// x = [456, 'abc']
(Here are quick guides if you're unfamiliar with tuples or as const
assertions)
You can refine a discriminated union:
type Shape = { shape: "circle"; radius: number } | { shape: "rectangle"; width: number; height: number }
const circle: Shape = { shape: "circle"; radius: 123 }
const rect: Shape = { shape: "rectangle"; width: 123, height: 123 }
const x = pipe(circle, set('shape:circle.radius', 456))
const y = pipe(rect, set('shape:circle.radius', 456))
// x = { shape: "circle"; radius: 456 }
// y = { shape: "rectangle"; width: 123, height: 123 }
(If you're not sure what a discriminated union is, here's a quick intro)
We can traverse an Array
to change its nested data
const x = pipe(
[{ a: 123 }, { a: 456 }],
set('[]>.a', 999)
)
// equivalent to:
const y = [{ a: 123 }, { a: 456 }].map(set('a', 999))
// x = y = [{ a: 999 }, { a: 999 }]
We can also traverse a Record
const rec: Record<string, { a: number }> =
{ two: { a: 456 }, one: { a: 123 } }
const x = pipe(rec, set('{}>.a', 999))
// x = { one: { a: 999 }, two: { a: 999 } }
We can change the value of an Array
at a particular index using [number]
. To preserve auto-complete, we have to pass in the index number
as a separate argument:
const array: { a: number }[] = [{ a: 123 }]
const x = pipe(array, set('[number].a', 0, 456))
// ^
// The index '0' comes after the path string '[number].a'
// x = [{ a: 456 }]
const y = pipe(array, set('[number].a', 1, 456))
// y = [{ a: 123 }]
Each 'index' in a path gets its own value argument
const nestedArray = [[], [{ a: 123 }]]
const x = pipe(nestedArray, set('[number].[number].a', 1, 0, 456))
// ^ ^
// Similar to nestedArray[1][0].a
// x = [[], [{ a: 456 }]]
You can set the value at an index of a Record in a similar way
const rec: Record<string, number> = { a: 123 }
const x = pipe(rec, set('[string]', 'a', 456))
// x = { a: 456 }
You can modify a value in relation to its old value:
import { modify } from 'spectacles-ts'
const x =
pipe({ a: { b: 123 } }, modify('a.b', b => b + 4))
// x = { a: { b: 127 } }
You can use this to e.g. append to an array
import * as A from 'fp-ts/ReadonlyArray'
const x = pipe(
{ a: [123] },
modify('a', A.append(456))
)
// x = { a: [123, 456] }
(For more on fp-ts, check out the appendix)
You can even change a value's type this way:
import { modifyW } from 'spectacles-ts'
// ^
// |
// The 'W' stands for 'widen'
// as in 'widen the type'
const x =
pipe([{ a: 123 }, { a: 456 }], modifyW('[number].a', 0, a => `${a + 4}`))
// x: { a: string | number }[]
// x = [{ a: "127" }, { a: 456 }]
And there are convenience operations for working with Option
and Either types
You can change an existing key:
import { upsert } from 'spectacles-ts'
const x = pipe(
{ a: { b: 123 } },
upsert('a', 'b', 'abc')
)
// x: { a: { b: string } }
// x = { a: { b: 'abc' } }
Or add a new one:
const x = pipe(
{ a: { b: 123 } },
upsert('a', 'c', 'abc')
)
// x: { a: { b: number; c: string } }
// x = { a: { b: 123, c: 'abc' } }
Or remove one of them:
import { remove } from 'spectacles-ts'
const x = pipe(
{ nest: { a: 123, b: 'abc', c: false } },
remove('nest.a')
)
// x: { nest: { b: string, c: boolean } }
// x = { nest: { b: 'abc', c: false } }
Or rename a key:
import { rename } from 'spectacles-ts'
const x = pipe(
{ nest: { a: 123 } },
rename('nest', 'a', 'a2')
)
// x: { nest: { a2: number } }
// x = { nest: { a2: 123 } }
You can also get
a value
import { get } from 'spectacles-ts'
const x = pipe({ a: { b: 123 } }, get('a.b'))
// x: number
// x = 123
// equivalent to
const y = { a: { b: 123 } }.a.b
// y: number
// y = 123
The curried functions from spectacles-ts
fit in nicely w/ a functional style
That's one reason you might want to use a function like get
:
const x = [{ a: 123 }].map(get('a'))
// x: number[]
// x = [123]
Since Array
access at a given index might fail, we use fp-ts's Option
type
import * as O from 'fp-ts/Option'
// |
// v
const x: O.Option<number> = pipe(array, get('[number].a', 0))
// x = O.some(123)
This also gives us a way to know when a 'set' call has failed, using setOption
:
import { set, setOption } from 'spectacles-ts'
const silentSuccess = pipe([123], set('[number]', 0, 999))
const silentFailure = pipe([123], set('[number]', 1, 999))
// silentSuccess: number[]
// silentFailure: number[]
// silentSuccess = [999]
// silentFailure = [123]
const noisySuccess = pipe([123], setOption('[number]', 0, 999))
const noisyFailure: O.Option<number[]> = pipe([123], setOption('[number]', 1, 999))
// noisySuccess: O.Option<number[]>
// noisyFailure: O.Option<number[]>
// noisySuccess = O.some([999])
// noisyFailure = O.none
(In case the Option
type is unfamiliar, check out the appendix for a bit more info)
Also featuring modifyOption and modifyOptionW
I hope spectacles-ts can help you modify data both immutably & ergonomically!
Follow me on twitter! @typesafeFE
You might have noticed a few references to the npm package called fp-ts. It's the latest in the line of successon of data utility libraries for javascript
underscore.js -> lodash -> ramda -> fantasy land -> fp-ts
fp-ts
stands for 'functional programming in typescript'. 'Functional programming' is just a style that emphasizes data transformations and type-safety
Usually functions from fp-ts
and its libraries (including spectacles-ts
) rely on pipe
You might be wondering what that function called pipe
is for
It can simplify the use of many nested functions
import { pipe } from 'fp-ts/function'
const manyfuncs = String(Math.floor(Number.parseFloat("123.456")));
const samething = pipe(
"123.456",
Number.parseFloat,
Math.round,
String
);
It's a bit easier to read in this format. We start with a string, then it's parsed into a number, then rounded, and then converted back into a string. It almost looks like a bulleted list!
Let's see what libraries that don't use pipe
look like
import { mapValues, filter } from 'lodash'
const data: Record<string, number> = { a: 1, b: 2, c: 3 }
const ugly = filter(
mapValues(data, (x) => x * 2),
(x) => x > 2
)
// ugly = { b: 4, c: 6 }
This is a bit difficult to read. mapValues
is nested inside filter
- this could get messy if we add more functions. We can imagine that this might look much nicer if our data were an array - something like data.map(x => ..).filter(x => ..)
. Is this possible with an object?
import _ from 'lodash'
const chained = _.chain(data)
.mapValues(x => x * 2)
.filter(x => x > 1)
.values()
// chained = { b: 4, c: 6 }
Much nicer! But this comes with a caveat - now we are importing all 600KB of lodash for two simple functions
pipe
gives us the best of both worlds:
import { pipe } from 'fp-ts/function'
import { map, filter } from 'fp-ts/ReadonlyRecord'
const piped = pipe(
data,
map(x => x * 2),
filter(x => x > 1)
)
// piped = { b: 4, c: 6 }
Legibility and economy - that's why we use pipe
as much as possible
Here's a more in-depth article about how pipe-able functions work. Here's one of the original articles motivating their use
The Option
type is a useful alternative to undefined
because it can nest
Consider the following problem:
const usernames: (string | undefined)[] = ["anthony", undefined, "stu"]
const atindex = usernames[4]
// atindex = undefined
We know that atindex
is undefined
, but we don't know what that means
It could be undefined
because the user chose to remain anonymous. In this case, though, it's undefined
because the user doesn't exist at all
Option
gives us a way to represent both of these cases
import { Option } from 'fp-ts/Option'
import { lookup } from 'fp-ts/ReadonlyArray'
const usernames: Option<string>[] = [O.some("anthony"), O.none, O.some("stu")]
const atindex: Option<Option<string>> = pipe(usernames, lookup(1))
// atindex = O.some(O.none)
atindex = O.some(O.none)
means that the user exists and is anonymous. atindex = O.none
means that the user never existed in the first place
For this reason Option
should generally be used instead of undefined
The Option
type is more powerful than undefined
. Options
can map
and flatten
, just like arrays and objects, and much more
Option
can be a great, simple intro into the joys of fp-ts
spectacles-ts
is built on top of monocle-ts, which is more powerful and flexible but a little less ergonomic.
Here's a side-by-side comparison between the two.
import { pipe } from 'fp-ts/lib/function'
import * as O from 'fp-ts/lib/Option'
import * as Op from 'monocle-ts/lib/Optional'
const optional = pipe(
Op.id<{ a: { b: readonly string[] } }>(),
Op.prop('a'),
Op.prop('b'),
Op.index(0),
)
const nestedMonocle =
optional.getOption({ a: { b: ['abc', 'def'] } })
// nestedMonocle: O.Option<string>
import { pipe } from 'fp-ts/function'
import { get } from 'spectacles-ts'
const nestedSpectacles =
pipe({ a : { b: ['abc', 'def'] } }, get('a.b.[number]', 0))
// nestedSpectacles: O.Option<string>
You can see the simplicity that spectacles-ts
offers
monocle-ts has these advantages:
spectacles-ts
only works in piped contexts (except for get)- No limitation on object size
- can filter (similar to es6's filter)
- can traverse on any arbitrary traversable object (aka Zippers or Rose Trees)
- Can define an isomorphism between two objects
- works with the Map type
An earlier version of spectacles used tuples for pathnames instead of string literals. This document has been updated to reflect the changes
CREDITS: Logo - Stuart Leach