DEV Community

Anthony G
Anthony G

Posted on • Updated on

Simple Immutable Data w/ Spectacles 👓

Rose-Colored Spectacles

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
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

It's that simple!

Try it out here!

(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 = {}
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

(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 }
Enter fullscreen mode Exit fullscreen mode

(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 }]
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

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 }]
Enter fullscreen mode Exit fullscreen mode

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 }]]
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

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] }
Enter fullscreen mode Exit fullscreen mode

(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 }]
Enter fullscreen mode Exit fullscreen mode

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' } }
Enter fullscreen mode Exit fullscreen mode

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' } }
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
);
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode

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

Oldest comments (7)

Collapse
 
steida profile image
Daniel Steigerwald

Why should I use it if I am using monocle-ts experimental?

Collapse
 
anthonyjoeseph profile image
Anthony G

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 and remove, 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

Collapse
 
patroza profile image
Patrick Roza • Edited

Why not use a path string and support unlimited depth?
get("a.b")

Collapse
 
anthonyjoeseph profile image
Anthony G • Edited

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

Collapse
 
anthonyjoeseph profile image
Anthony G

Thanks for taking a look at the library!

Collapse
 
anthonyjoeseph profile image
Anthony G

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!

Collapse
 
patroza profile image
Patrick Roza • Edited

You may want to look at tsplus, so you can combine the best of both worlds:

  • Fluent for ergonomics, discoverability
  • Compiled down to module imports for tree shaking

also

  • Can inject globals at compile time, so you don't need to import your most used modules, variables, functions etc
  • Operators, and more.

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 ;-)