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)!
Installation
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)
Nullables
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 = {}
Tuples
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)
Discriminated Union
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)
Traversals
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 } }
Indexed Arrays
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 }
Modification
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
Change Object 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 } }
get
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]
Option
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
Conclusion
I hope spectacles-ts can help you modify data both immutably & ergonomically!
Follow me on twitter! @typesafeFE
Appendix: functional programming
Whats fp-ts
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
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!
Why use pipe for spectacles
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
Whats Option
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
vs monocle-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
Note
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
Top comments (7)
Why not use a path string and support unlimited depth?
get("a.b")
Just wanted to let you know - I ended up taking your suggestion! It didn't end up helping w/ the depth issue (I used tail-recursive types for that), but it did improve autocomplete & inference (tuples had this issue)
Thanks for the help!
The depth limit is due to tsc memory issues, and afaict there's no memory savings using a template literal vs a tuple. Though I am curious how template string autocomplete would look (if it could exist at all) - might be worth an experiment
Thanks for taking a look at the library!
You may want to look at tsplus, so you can combine the best of both worlds:
also
install with github.com/ts-plus/installer
sample impl at github.com/ts-plus/stdlib/blob/mai...
usage example: github.com/ts-plus/stdlib/blob/mai...
You can basically make your library tsplus "compatible" or let's say - easily consumable, without taking a hard dependency. Because it's basically JSDoc annotations.
Oh there's a post by the man himself now :) dev.to/matechs/the-case-for-ts-18b3
(seeing your scala/fp interest, you may also follow down the rabbit-hole into Effect-TS, where the next branch will be fully ts-plus powered ;-)
Why should I use it if I am using monocle-ts experimental?
If you're happy w/ monocle-ts, you shouldn't! The main advantage is just ergonomics - spectacles-ts is a bit more terse & easier for a beginner to pick up (hopefully)
The idea is to make this type of functionality more accessible & simpler to use, to be able to reach as broad an audience as possible.
P.S.
There is some functionality that doesn't exist in monocle-ts - yet.
upsert
andremove
, for example, are planned to come to monocle-ts at some point. Basically, if it modifies the underlying structure of the object, it doesn't exist in monocle, but it should arrive at some point