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? Get some clarity 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'
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!

(If pipe syntax is unfamiliar checkout this quick explanation)

You can get a value with similar syntax:

import { get } from 'spectacles-ts'

const num: number = pipe({ a: { b: 123 } }, get('a', 'b'))

// equivalent to
const num2: number = { a: { b: 123 } }.a.b

// num = num2 = 123
Enter fullscreen mode Exit fullscreen mode

Functional Programming (fp)

spectacles-ts integrates seamlessly with the fp-ts ecosystem (it's built on top of the excellent monocle-ts library)

Its curried functions fit in nicely w/ a functional style

That's one reason you might want to use a function like get:

const as: number[] = [{ a: 123 }].map(get('a'))
// as = [123]
Enter fullscreen mode Exit fullscreen mode

Array access

We can do Array access using a number for the index:

const a = pipe([{ a: 123 }], get(0, 'a'))
Enter fullscreen mode Exit fullscreen mode

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 a: O.Option<number> = pipe([{ a: 123 }], get(0, 'a'))
// a = O.some(123)
Enter fullscreen mode Exit fullscreen mode

The Option type is powerful, featuring a full set of combinators. It can be a great, simple intro into the joys of fp-ts

This also gives us a way to know when a 'set' call has failed, using setOption:

import { set, setOption } from 'spectacles-ts'

const silentSuccess: number[] = pipe([123], set([0], 999))
const silentFailure: number[] = pipe([123], set([1], 999))
// silentSuccess = [999]
// silentFailure = [123]

const noisySuccess: O.Option<number[]> = pipe([123], setOption([0], 999))
const noisyFailure: O.Option<number[]> = pipe([123], setOption([1], 999))
// noisySuccess = O.some([999])
// noisyFailure = O.none
Enter fullscreen mode Exit fullscreen mode

Traversals

We can traverse an Array to collect its nested data

const a: number[] = pipe(
  [{ a: 123 }, { a: 456 }],
  get('[]>', 'a')
)

// equivalent to:
const a2: number[] = [{ a: 123 }, { a: 456 }].map(get('a'))

// a = a2 = [123, 456]
Enter fullscreen mode Exit fullscreen mode

Or to make a change across all of its values

const a: { a: number }[] = pipe(
  [{ a: 123 }, { a: 456 }],
  set(['[]>', 'a'], 999)
)

// a = [{ a: 999 }, { a: 999 }]
Enter fullscreen mode Exit fullscreen mode

We can also traverse a Record. The keys are sorted alphabetically

const rec = 
  { two: { a: 456 }, one: { a: 123 } } as Record<string, { a: number }>
const a: number[] = pipe(rec, get('{}>', 'a'))

// a = [123, 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 mod: { a: number }[] =
  pipe([{ a: 123 }], modify([0, 'a'], a => a + 4))
// mod = [{ a: 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 app: { a: number[] } = pipe(
  { a: [123] },
  modify(['a'], A.append(456))
)
// app = { a: [123, 456] }
Enter fullscreen mode Exit fullscreen mode

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 modW: { a: string | number }[] =
  pipe([{ a: 123 }], modifyW([0, 'a'], a => `${a + 4}`))
// mod = { a: '127' }
Enter fullscreen mode Exit fullscreen mode

Also featuring modifyOption and modifyOptionW

Change Object types

You can change an existing key:

import { upsert } from 'spectacles-ts'

const obj: { a: { b: string} } = pipe(
  { a: { b: 123 } }, 
  upsert(['a', 'b'], 'abc')
)
// obj = { a: { b: 'abc' } }
Enter fullscreen mode Exit fullscreen mode

Or add a new one:


const obj: { a: { b: number; c: string } } = pipe(
  { a: { b: 123 } }, 
  upsert(['a', 'c'], 'abc')
)
// obj = { a: { b: 123, c: 'abc' } }
Enter fullscreen mode Exit fullscreen mode

Or remove a few of them:

import { remove } from 'spectacles-ts'

const removedKeys: { nest: { b: string } } = pipe(
  { nest: { a: 123, b: 'abc', c: false } }, 
  remove('nest', ['a', 'c'] as const)
)
// removedKeys = { nest: { b: 'abc' } }
Enter fullscreen mode Exit fullscreen mode

Or rename a key:

import { rename } from 'spectacles-ts'

const renamedKey: { nest: { a2: number } } = pipe(
  { nest: { a: 123 } }, 
  rename(['nest', 'a'], 'a2')
)
// renamedKey = { nest: { a2: 123 } }
Enter fullscreen mode Exit fullscreen mode

Other stuff

You can access the index of a tuple:

const tup = [123, 'abc'] as [number, string]
const getIndex: number = pipe(tup, get('0'))
// getIndex = 123
Enter fullscreen mode Exit fullscreen mode

You can pick a few keys:

const pickedKeys: { a: number; c: boolean } = pipe(
  { nest: { a: 123, b: 'abc', c: false } }, 
  get(['nest', ['a', 'c'] as const])
)
// pickedKeys = { a: 123, c: true }
Enter fullscreen mode Exit fullscreen mode

You can access a nullable value:

interface Obj { a?: { b: number } }
const obj: Obj = { a: { b: 123 } }
const a: O.Option<number> = pipe(a, get('a', '?', 'b'))
// a = O.some(123)
Enter fullscreen mode Exit fullscreen mode

You can access a key of a record:

const rec = { a: 123 } as Record<string, number>
const getKey: O.Option<number> = pipe(rec, get('?key', 'a'))
// getKey = O.some(123)
Enter fullscreen mode Exit fullscreen mode

You can refine a union type:

const refined: O.Option<number> = pipe(
   { a: 123 } as { a: string | number },
   get('a', (a): a is number => typeof a === 'number')
)
// refined = O.some(123)
Enter fullscreen mode Exit fullscreen mode

And there are convenience operations for working with Option and Either types

Limitation

You can only use up to four operations at a time (Alas!)

You can nest functions instead:

import { pipe } from 'fp-ts/function'
import { get, set, modify } from 'spectacles-ts'

const getDeep: number = pipe(
  { a: { b: { c: { d: { e: 123 } } } } },
  get('a', 'b', 'c', 'd'),
  get('e')
)

const setDeep = pipe(
  { a: { b: { c: { d: { e: 123 } } } } },
  modify(
    ['a', 'b', 'c', 'd'],
    set(['e'], 321)
  )
)
Enter fullscreen mode Exit fullscreen mode

Nesting functions that change their output type looks a little uglier, but it works:

const upsertDeep: { a: { b: { c: { d: { e: number; e2: string } } } } } = pipe(
  { a: { b: { c: { d: { e: 123 } } } } },
  modifyW(
    ['a', 'b', 'c', 'd'],
    val => pipe(
      val,
      upsert(['e2'], 'abc')
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

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 direct 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: O.Option<string> =
  optional.getOption({ a: { b: ['abc', 'def'] } })
Enter fullscreen mode Exit fullscreen mode
import { pipe } from 'fp-ts/function'
import { get } from 'spectacles-ts'

const nestedSpectacles: O.Option<string> = 
  pipe({ a : { b: ['abc', 'def'] } }, get('a', 'b', 0))
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

Conclusion

I hope spectacles-ts can help you modify data both immutably & ergonomically!


CREDITS:
Logo - Stuart Leach

Discussion (2)

Collapse
steida profile image
Daniel Steigerwald

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

Collapse
anthonyjoeseph profile image
Anthony G Author

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