Skip to content

Latest commit

 

History

History
463 lines (327 loc) · 13.4 KB

blog-post.md

File metadata and controls

463 lines (327 loc) · 13.4 KB

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

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!

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 = {}

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