Skip to content

AlexGalays/spacelift

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

space-lift "Lift your values into space for infinite possibilities"

Note: Starting from version 1.0.0, space-lift no longer contains the Option and Result monads. You can find these at space-monad

Rich Array, Object, Map, Set wrapper

Design goals

  • 100% immutable, no magic, no overwhelming polymorphism or dynamic operators
  • Fun to use
  • Correctness and first-class typescript typings
  • Tiny and performant (space-lift weights 7.2 kB minified for roughly the same scope as lodash (70.0 kB) AND immerjs (15.6 kB)) and no amount of tree shaking can save you from heavy abstractions imported by all modules.
  • Small set of functions, configurable with lambdas
  • Cover 95% of frontend data transformation needs without becoming a bloated lib just to cover the remaining 5%

spacelift lib size

How to use

Here's everything that can be imported from space-lift:

import {
  lift,
  update,
  range,
  is,
  createUnion,
  createEnum,
  identity,
  noop,
  Result,
  Ok,
  Err,
  Immutable
} from 'space-lift'
  • lift is the main attraction and is used to wrap an Array, Object, Map or Set to give it extra functionalities
  • update can update an Object, Array, Map or Set without modifying the original
  • range is a factory function for Arrays of numbers
  • is is a helper used to determine if an instance is of a particular type (e.g is.array([]) === true)
  • createUnion creates a bunch of useful things when working with discriminated unions.
  • createEnum creates a bunch of useful things when working with a string based enum.
  • identity the identity function
  • noop a function that does nothing
  • Result, Ok, Err are used to work with computation that may fail
  • Immutable a helper type that will recursively make a tree Readonly.

Some Examples

Update an object inside an Array

import { update } from 'space-lift'

const people = [
  { id: 1, name: 'jon' },
  { id: 2, name: 'sarah' },
  { id: 3, name: 'nina' }
]

const updatedPeople = update(people, draft => {
  draft.updateIf(
    p => p.id === 2,
    personDraft => {personDraft.name = 'Nick'})
})

Sort on two fields

import lift from 'space-lift'

const people = [
  { first: 'jon',   last: 'haggis' },
  { first: 'sarah', last: 'john' },
  { first: 'nina',  last: 'pedro' }
]

// This will create an Array sorted by first name, then by last name
const sortedPeople = lift(people)
  .sort(p => p.first, p => p.last)
  .value()

API

Array

Array.append

Appends one item at the end of the Array.

import {lift} from 'space-lift'
const updated = lift([1, 2, 3]).append(4).value() // [1, 2, 3, 4]

Array.appendAll

Appends an Iterable of items at the end of the Array.

import {lift} from 'space-lift'
const updated = lift([1, 2, 3]).appendAll([4, 5]).value() // [1, 2, 3, 4, 5]

Array.compact

Filters all the falsy elements out of this Array.
All occurences of false, null, undefined, 0, "" will be removed.

import {lift} from 'space-lift'
const updated = lift([1, null, 2, 3, undefined]).compact().value() // [1, 2, 3]

Array.count

Counts the items satisfying a predicate.

import {lift} from 'space-lift'
const count = lift([1, 2, 3]).count(n => n > 1) // 2

Array.collect

Maps this Array's items, unless void or undefined is returned, in which case the item is filtered.
This is effectively a filter + map combined in one.

import {lift} from 'space-lift'
const count = lift([1, 2, 3]).collect(n => {
  if (n === 1) return;
  return `${n*10}`
}).value() // ['20', '30']

Array.distinct

Creates an array without any duplicate item.
If a key function is passed, items will be compared based on the result of that function;
if not, they will be compared using strict equality.

import {lift} from 'space-lift'

const people = [{id: 1, name: 'Alexios'}, {id: 2, name: 'Bob'}, {id: 1, name: 'Alessia'}]

// [{id: 1, name: 'Alexios'}, {id: 2, name: 'Bob'}]
const deduped = lift(people).distinct(p => p.id).value()

Array.drop

Drops the first 'count' items from this Array.

import {lift} from 'space-lift'
const updated = lift([1, 2, 3]).drop(2).value() // [3]

Array.dropRight

Drops the last 'count' items from this Array.

import {lift} from 'space-lift'
const updated = lift([1, 2, 3]).dropRight(2).value() // [1]

Array.filter

Filters this array by aplying a predicate to all items and refine its type.

import {lift} from 'space-lift'
const filtered = lift([1, 2, 3]).filter(n => n > 1).value() // [2, 3]

Array.first

Returns the first element in this Array or undefined.

import {lift} from 'space-lift'
const first = lift([1, 2, 3]).first() // 1

Array.flatMap

Maps this Array to an Array of Array | ArrayWrapper using a mapper function then flattens it.

import {lift} from 'space-lift'
const mapped = lift([1, 2, 3]).flatMap(n => [n + 1, n + 2]).value() // [2, 3, 3, 4, 4, 5]

Array.flatten

Flattens this Array of Arrays.

import {lift} from 'space-lift'
const flattened = lift([1, [2], [3, 4]]).flatten().value() // [1, 2, 3, 4]

Array.reduce

Reduces this Array into a single value, using a starting value.

import {lift} from 'space-lift'
const count = lift([1, 2, 3]).reduce(0, (count, n) => count + n) // 6

Array.get

Returns the item found at the provided index or undefined.

import {lift} from 'space-lift'
const secondItem = lift([1, 2, 3]).get(1) // 2

Array.groupBy

Creates a Map where keys are the results of running each element through a discriminator function.
The corresponding value of each key is an array of the elements responsible for generating the key.

import {lift} from 'space-lift'
const people = [
  { age: 10, name: 'jon' },
  { age: 30, name: 'momo' },
  { age: 10, name: 'kiki' },
  { age: 28, name: 'jesus' },
  { age: 29, name: 'frank' },
  { age: 30, name: 'michel' }
]

// Map<number, Array<{age: number, name: string}>>
const peopleByAge = lift(people).groupBy(p => p.age).value()

Array.grouped

Creates a new Array where each sub array contains at most 'bySize' elements.

import {lift} from 'space-lift'
const numbers = [1, 2, 3, 4, 5, 6, 7]

// [[1, 2], [3, 4], [5, 6], [7]]
const groupedNumbers = lift(numbers).grouped(2).value()

Array.insert

Inserts an item at a specified index.

import {lift} from 'space-lift'
const updated = lift(['1', '2', '3']).insert(1, '20').value() // [1, 20, 2, 3]

Array.last

Returns the item found at the last index or undefined.

import {lift} from 'space-lift'
const last = lift(['1', '2', '3']).last() // '3'

Array.map

Maps this Array using a mapper function.

import {lift} from 'space-lift'
const mapped = lift(['1', '2', '3']).map(str => '0' + str).value() // ['01', '02', '03']

Array.removeAt

Removes the item found at the specified index.

import {lift} from 'space-lift'
const updated = lift(['1', '2', '3']).removeAt(1).value() // ['1', '3']

Array.reverse

Reverses the Array.

import {lift} from 'space-lift'
const updated = lift(['1', '2', '3']).reverse().value() // ['3', '2', '1']

Array.sort

Sorts the Array in ascending order, using one or more iterators specifying which field to compare.
For strings, localCompare is used.
The sort is stable if the browser uses a stable sort (all modern engines do)

import {lift} from 'space-lift'

const people = [
  { name: 'Jesse', creationDate: 2 },
  { name: 'Walt', creationDate: 1 },
  { name: 'Mike', creationDate: 4 },
  { name: 'Skyler', creationDate: 3 }
]

const sorted = lift(people)
  .sort(p => p.creationDate)
  .map(p => p.name)
  .value() // ['Walt', 'Jesse', 'Skyler', 'Mike']

Array.take

Takes the first 'count' items from this Array.

import {lift} from 'space-lift'
const updated = lift(['1', '2', '3']).take(2).value() // ['1', '2']

Array.takeRight

Takes the last 'count' items from this Array.

import {lift} from 'space-lift'
const updated = lift(['1', '2', '3']).takeRight(2).value() // ['2', '3']

Array.toSet

Converts this Array to a Set.

import {lift} from 'space-lift'
const set = lift(['1', '2', '2', '3']).toSet().value() // Set(['1', '2', '3'])

Array.updateAt

Updates an item at the specified index.

import {lift} from 'space-lift'
const updated = lift(['1', '2', '2', '3']).updateAt(1, '20').value() // ['1', '20', '2', '3']

Array.pipe

Pipes this Array with an arbitrary transformation function.

import {lift} from 'space-lift'
const updated = lift([1, 0, 3]).pipe(JSON.stringify) // '[1, 0, 3]'

Object

Object.add

Adds a new key/value to this object. This creates a new type.
To add a nullable key to an object while preserving its type, use update instead.

import {lift} from 'space-lift'

const updated = lift({a: 1, b: 2}).add(c, 3).value() // {a: 1, b: 2, c: 3}

Object.isEmpty

Returns whether this object contains no keys.

import {lift} from 'space-lift'

const isEmpty = lift({a: 1, b: 2}).isEmpty() // false

Object.keys

Creates an Array of all this object's keys, in no particular order.
If the keys are a subtype of string, the Array will be typed with the proper key union type.

import {lift} from 'space-lift'

const isEmpty = lift({a: 1, b: 2}).keys().value() // ['a', 'b']

Object.mapValue

Maps one of this Object values, by key.
This is similar to remove('key').add('key', newValue) but is less error prone.
This can change the type of the object.

import {lift} from 'space-lift'

const mappedValues = lift({
  a: 1,
  b: 2
})
.mapValue('b', num => `${num * 2}`)
.value() // { a: 1, b: '4' }

Object.mapValues

Maps this Object values using a mapper function.
This is mostly useful for objects with a single value type.

import {lift} from 'space-lift'

const mappedValues = lift({
  chan1: [1, 2, 3],
  chan2: [10, 11, 12]
})
.mapValues(numbers => numbers.map(n => n * 2))
.value() // { chan1: [2, 4, 6], chan2: [20, 22, 24] }

Object.pipe

Pipes this Object with an arbitrary transformation function.

import {lift} from 'space-lift'

const updated = lift({a: 1}).pipe(JSON.stringify) // '{"a": 1}'

Object.remove

Removes a key/value from this object and return a new object (and type)
To delete a (nullable) key from an object while preserving its type, use "update()" instead.

import {lift} from 'space-lift'

const updated = lift({a: 1, b: 2, c: 3}).remove('c').value() // {a: 1, b: 2}

Object.values

Creates an Array with all these object's values.

import {lift} from 'space-lift'

const updated = lift({a: 1, b: 2, c: 3}).values().value() // [1, 2, 3]

Object.toArray

Converts this Object to an Array of tuples.
Similar to Object.entries() but retains the type of keys.

import {lift} from 'space-lift'

const updated = lift({a: 1, b: 2, c: 3}).toArray().value() // [['a', 1], ['b', 2], ['c', 3]]

Object.toMap

Transforms this Object to a Map where the keys are the string typed keys of this Object.

import {lift} from 'space-lift'

const updated = lift({a: 1, b: 2, c: 3}).toMap().value() // Map([['a', 1], ['b', 2], ['c', 3]])

Map

Map.set

Sets a new key/value.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const updated = lift(map).set('c', 3).value() // Map([['a', 1], ['b', 2], ['c', 3]])

Map.delete

Deletes a key/value.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const updated = lift(map).delete('b').value() // Map([['a', 1]])

Map.collect

Maps this Map's keys and values, unless void or undefined is returned, in which case the entry is filtered.
This is effectively a filter + map combined in one.

import {lift, update} from 'space-lift'

const map = new Map([
  [1, { id: 1, name: 'aa' }],
  [2, { id: 2, name: 'bb' }]
])

const updated = lift(map).collect((key, value) => {
  if (key === 2) return
  return [
    key * 10,
    update(value, v => { v.name = `${v.name}$` })
  ]
}).value() // Map([[10, {id: 2, name: 'bb$'}]])

Map.filter

Filters this Map's keys and values by aplying a predicate to all values and refine its type.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const updated = lift(map).filter((key, value) => key === 1).value() // Map([['a', 1]])

Map.first

Returns the first element in this Map or undefined.

import {lift} from 'space-lift'

const map = new Map([
  [1, { id: 1, name: 'Walter' }],
  [2, { id: 2, name: 'Jesse' }]
])

const first = lift(map).first() // { id: 1, name: 'Walter' }

Map.last

Returns the last element in this Map or undefined.

import {lift} from 'space-lift'

const map = new Map([
  [1, { id: 1, name: 'Walter' }],
  [2, { id: 2, name: 'Jesse' }]
])

const first = lift(map).last() // { id: 2, name: 'Jesse' }

Map.mapValues

Maps this map's values.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const updated = lift(map).filter(value => value * 2).value() // Map([['a', 2], ['b', 4]])

Map.pipe

Pipes this Map with an arbitrary transformation function.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const yay = lift(map).pipe(m => m.toString()) // '[object Map]' 

Map.setDefaultValue

If this key is missing, set a default value.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const map = lift(map)
  .setDefaultValue('c', 3)
  .setDefaultValue('b', 10)
  .value() // Map([['a', 1], ['b', 2], ['c', 3]])

Map.updateValue

Same as update(map, draft => draft.updateValue, exposed here for convenience and readability as it's often used immediately after setDefaultValue.

import {lift} from 'space-lift'

const map = new Map([
  ['a', {name: 'a'}],
  ['b', {name: 'b'}]
])

const map = lift(map).updateValue('b', draft => { draft.name = 'c' }).value()

Map.toArray

Transforms this Map into an Array of [key, value] tuples.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const array = lift(map).toArray().value() // [ ['a', 1], ['b', 2] ]

Map.toObject

Transforms this Map into an Object.
Only available if this Map's keys are a subtype of string or number.

import {lift} from 'space-lift'

const map = new Map([
  ['a', 1],
  ['b', 2]
])

const array = lift(map).toObject().value() // { 'a': 1, 'b': 2 }

Set

Set.add

Adds a new value to this Set.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const updated = lift(set).add(4).add(3).value() // Set([1, 2, 3, 4])

Set.addAll

Adds all items from the passed iterable to this Set.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const updated = lift(set).addAll([4, 5, 3]).value() // Set([1, 2, 3, 4, 5])

Set.delete

Deletes one value from this Set.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const updated = lift(set).delete(2).value() // Set([1, 3])

Set.collect

Maps this Set's items, unless void or undefined is returned, in which case the item is filtered.
This is effectively a filter + map combined in one.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const updated = lift(set).collect(num => {
  if (num === 2) return
  return num * 2
}).value() // Set([2, 6])

Set.filter

Filters this Set's items by aplying a predicate to all values and refine its type.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const updated = lift(set).filter(num => num !== 2).value() // Set([1, 3])

Set.intersection

Returns the Set of all items of this Set that are also found in the passed Set.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])
const otherSet = new Set([2, 3, 4])

const intersection = lift(set).intersection(otherSet).value() // Set([2, 3])

Set.difference

Returns the Set of all items of this Set that are not found in the passed Set.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])
const otherSet = new Set([2, 3, 4])

const diff = lift(set).difference(otherSet).value() // Set([1])

Set.pipe

Pipes this Set with an arbitrary transformation function.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const yay = lift(set).pipe(s => s.toString()) // '[object Set]' 

Set.toArray

Transforms this Set into an Array. The insertion order is kept.

import {lift} from 'space-lift'

const set = new Set([1, 2, 3])

const array = lift(set).toArray().value() // [1, 2, 3]

update

update is your go-to function to perform immutable updates on your Objects, Array, Map and Set, using a mutable API. If you know immerjs, it's very similar (great idea!) but with different design constraints in mind:

  • Tiny implementation (The entirety of space-lift is way smaller than immerjs)
  • The Array draft has special methods to update it as the traditional mutable Array API in JavaScript is awful.
  • Instead of eagerly creating tons of costly Proxies (also called drafts), they are created only when strictly needed (look for: will create a draft in the documentation below).
  • drafts are only created for values of type Object, Array, Map or Set.
  • update should never have a returned value and will prevent it at the type level.
  • Remember that if you iterate through keys, values, etc drafts will NOT be created by default. Call one of the draft creating methods within the loop to perform the updates conditionally.
  • As long as you keep accessing drafts, the update can be done at any level of a tree.

update for Object

Accessing a draft object property is the only Object operation that will create a draft

Adding/updating an Object property

import {update} from 'space-lift'

const obj: { a: 1; b?: number } = { a: 1 }

const updated = update(obj, draft => {
  draft.b = 20
})

Deleting an Object property

import {update} from 'space-lift'

const obj: { a: 1; b?: number } = { a: 1, b: 20 }

const updated = update(obj, draft => {
  delete draft.b
})

update for Map

All regular methods are available.

  • get will will create a draft for the returned value.
  • setDefaultValue will create a draft

Map - Updating an existing value

import {update} from 'space-lift'

const map = new Map([
  [1, { id: 1, name: 'jon' }],
  [2, { id: 2, name: 'Julia' }]
])

const updated = update(map, draft => {
  const value = draft.get(2)
  if (value) return
  
  value.name = 'Bob'
})

Map - Using updateValue

If the key is found, run the drafted value through an update function. For primitives, the update function must return a new value whereas for objects, the drafted value can be modified directly.

import {update} from 'space-lift'

const map = new Map([
  [1, { id: 1, name: 'jon' }],
  [2, { id: 2, name: 'Julia' }]
])

const updated = update(map, draft =>
  draft.updateValue(2, value => { value.name = 'Julia' }))

update for Set

All regular Set methods are available.
None of the Set draft methods will create a draft as a Set never hands value over.
Still, it's useful to update an immutable Set whether it's found nested in a tree or not and Sets are most of the time only useful for primitives values that wouldn't be drafted.

update for Array

Most Array methods are available but some are removed to make working with Arrays more pleasant:

  • splice: Replaced by insert, removeIf.
  • unshift: Replaced by preprend.
  • shift: Replaced by removeIf.
  • pop: Replaced by removeIf.
  • push: Replaced by append.
  • map is not removed but updateIf is added as the conceptual, mutable equivalent.

As a result, the interface of a draft Array is not fully compatible with Array/ReadonlyArray and you must use toDraft if you want to assign a regular Array to a draft Array.

  • Accessing an Array element by index will create a draft (be careful with this if you somehow end up manually iterating the Array)
  • updateIf will create a draft for each item satisfying its predicate.

Array - using updateIf

import {update} from 'space-lift'

const arr = [
  { id: 1, name: 'Jon' },
  { id: 3, name: 'Julia' }
]

const updated = update(arr, draft => {
  draft.updateIf(
    (item, index) => item.id === 3,
    item => {
      item.name = 'Bob'
    }
  )
})

createEnum

Creates a type safe string enumeration from a list of strings, providing:
the list of all possible values, an object with all enum keys and the derived type of the enum in a single declaration.

  import { createEnum } from 'space-lift/es/enum'

  const color = createEnum('green', 'orange', 'red')

  // We can use the derived type
  export type Color = typeof color.T

  // We can list all enum values as a Set.
  color.values // Set(['green', 'orange', 'red'])

  // We can access each value of the enum directly if that's useful
  export const Color = color.enum

  const redish: Color = 'red'
  const greenish: Color = Color.green
  const orange: 'orange' = Color.orange
  orange // 'orange'

createUnion

Creates a type-safe union, providing: derived types, factories and type-guards in a single declaration.

  import { createUnion } from 'space-lift'

  // Let's take the example of a single input Form that can send a new message or edit an existing one.
  // createUnion() gives you 3 tools:
  // T: the derived type for the overall union
  // is: a typeguard function for each state
  // Lastly, the returned object has a key acting as a factory for each union member
  const formState = createUnion({
    creating: () => ({}),
    editing: (msgId: string) => ({ msgId }),
    sendingCreation: () => ({}),
    sendingUpdate: (msgId: string) => ({ msgId }),
  });

  // The initial form state is 'creating'
  let state: typeof formState.T = formState.creating() // { type: 'creating' }

  // If the user wants to edit an existing message, we have to store the edited message id. Lets update our state.
  onClickEdit(msgId: string) {
    state = formState.editing(msgId) // { type: 'editing', msgId: 'someId' }
  }

  // In edition mode, we could want to get the message and change the send button label
  if (formState.is('editing')(state)) {
    getMessage(state.msgId) // thanks to the typeguard function, we know msgId is available in the state
    buttonLabel = 'Update message'
  }

  // If needed, we can also access the derived type of a given state
  type EditingType = typeof formState.editing.T
  const editingObj: EditingType = formState.editing('someId')

Result

A Result is the result of a computation that may fail. An Ok represents a successful computation, while an Err represent the error case.

Importing Result

Here's everything that can be imported to use Results:

import { Result, Ok, Err } from 'space-lift'

const ok = Ok(10) // {ok: true, value: 10}
const err = Err('oops') // {ok: false, error: 'oops'}

toDraft

TS currently has a limitation where this library must type its getter the same as its setters. Thus, if you want to assign an entirely new value that contains a type not compatible with its drafted type (so anything but primitives and objects) you will need to use toDraft:

import {update, toDraft} from 'space-lift'

const updated = update({arr: [1, 2, 3]}, draft => {
  draft.arr = toDraft([4, 5, 6])
})

This limitation might be fixed one day: TS ticket

Auto unwrap

Most of the time, you will have to call .value() to read your value back.
Because it's distracting to write .value() more than once per chain, some operators will automatically unwrap values returned from their iterators (like Promise->then). These operators are:

  • Array.map
  • Array.flatMap
  • Array.updateAt
  • pipe