handrail

a toolset for logical disjunctions / safety for your functional pipelines

Usage no npm install needed!

<script type="module">
  import handrail from 'https://cdn.skypack.dev/handrail';
</script>

README

handrail

a toolset for adding safety to your functional pipelines

Please read the accompanying post for more in depth explanation.

This utility adds logical disjunction / railway-oriented programming to your functional pipelines.

NB: See this file in a runnable form here: example.literate.js

Install

yarn add handrail -S

or

npm i handrail -S

Use

Here's an all-in-one example where we can make an unsafe function safer while not modifying the original:

import {guideRail, fold} from 'handrail'
import pipe from 'ramda/src/pipe'

// here are two potential error cases
const over21 = ({age}) => age > 20
const hasMoney = ({cash}) => cash - 5 >= 0

// and these are the cases we pass to the end, before folding
const growUp = (user) => `Expected ${user.name} to be 21!`
const getAJob = (user) => `Expected ${user.name} to have at least 5 dollars!`

// here's our original function, which has some errors in its assumptions
const bartenderOfIllRepute = (user) => {
  user.cash -= 5
  user.beverages = user.beverages || []
  user.beverages.push(`beer`)
  return user
}

// here's how we fix it with `guideRail`
const bartenderOfGoodRepute = pipe(
  guideRail(
    [
      // add safety for age!
      [over21, growUp],
      // add safety for cash!
      [hasMoney, getAJob]
      // add more!
    ],
    // alter the Either value
    bartenderOfIllRepute
  ),
  // this just pulls our value out from the Either (see the [fold API](https://github.com/brekk/handrail#fold) below)
  fold(I, I)
)

Example

Here's a contrived problem that handrail can help us solve:

  1. Jimmy and Alice want to go drinking, but Jimmy isn't of legal drinking age.

    const resetUsers = () => ({
      alice: {name: `alice`, cash: 15, age: 22},
      jimmy: {name: `jimmy`, cash: 20, age: 20}
    })
    
    let {alice, jimmy} = resetUsers()
    
    
  2. There's an unscrupulous bartender (in the form of a function) who doesn't enforce the rules.

const unscrupulousBartender = (user) => {
  user.cash -= 5
  user.beverages = user.beverages || []
  user.beverages.push(`beer`)
  return user
}

console.log(`=== example one ===`)
console.log(`alice goes to the bar`, unscrupulousBartender(alice))
// {name: `alice`, cash: 10, beverages: [`beer`], age: 22}
console.log(`jimmy goes to the bar`, unscrupulousBartender(jimmy))
// {name: `jimmy`, cash: 15, beverages: [`beer`], age: 20}
  1. But we're part of a team that's trying to crack down on unscrupulous bartenders, and we'd like to use handrail to solve this problem.
// import {handrail} from "handrail"
const {handrail} = require(`./handrail`)

const ageAttentiveBartender = handrail(
  (user) => user.age > 20,
  (user) => `Expected ${user.name} - (age: ${user.age}) to be at least 21.`,
  unscrupulousBartender
)

console.log(`=== example two ===`)
console.log(`alice goes to the bar behaving legally`, ageAttentiveBartender(alice))
// { r: { name: 'alice', cash: 5, age: 22, beverages: [ 'beer', 'beer' ] } }
console.log(`jimmy goes to the bar behaving legally`, ageAttentiveBartender(jimmy))
// { l: 'Expected jimmy - (age: 20) to be at least 21.' }

Hey, now we're seeing an altered behavior, but why is this {r/l} object wrapped around our values?

This is an Either; it's either a Left or a Right. In either case, when we wanna grab a value out of the result, we simply have to use fold from 'handrail' to get a resolving value.

// import {fold} from 'handrail'
const {fold} = require(`./handrail`)

fold takes three parameters. The first two are functions, the first is invoked when the value is a Left, and the other is invoked when the value is a Right. Finally, the last parameter is an Either (Left / Right). This is a curried function, so you can specify what to do as a resolution well before you have an Either.

// here's a simple one
const logOrWarn = fold(console.error, console.log)

Now we can tack on this resolution value to our previously-error producing function using pipe

const pipe = require(`ramda/src/pipe`)
const ageAttentiveBartender2 = pipe(ageAttentiveBartender, logOrWarn)

console.log(`=== example three ===`)
console.log(`alice goes to the bar behaving legally, round 2`)
ageAttentiveBartender2(alice)
/*
{ name: 'alice',
  cash: 0,
  age: 22,
  beverages: [ 'beer', 'beer', 'beer' ] }
*/
console.log(`jimmy goes to the bar behaving legally, round 2`)
ageAttentiveBartender2(jimmy)

Oh! Now we've added age-safety to our bar!

However, let's say that we've spotted another issue with our current function -- it doesn't care if the given user doesn't have cash to cover the beer.

console.log(`=== example four ===`)
console.log(`alice can go into debt with the bar!`)
ageAttentiveBartender2(alice)
/*
{ name: 'alice',
  cash: -5,
  age: 22,
  beverages: [ 'beer', 'beer', 'beer', 'beer' ] }
*/

So, rather than continuing to make Alice more drunk and more in debt, let's call resetUsers:

let soberUsers = resetUsers()
alice = soberUsers.alice
jimmy = soberUsers.jimmy

And let's see what we can do (relative to our original unscrupulousBartender implementation above) to add both age & cash safety to our function.

We'll use rail and multiRail, which will allow us to add more than one assertion / form of safety to our original bartending function:

// import {rail, multiRail} from 'handrail'
const {rail, multiRail} = require(`./handrail`)

(NB: This example leans a little more heavily on an understanding of pipe, which is described in more detail here. Simple example: pipe((x) => x + 5, (y) => y - 7) is the same as a new function (z) => z - 2)

/* for easier recall:
const unscrupulousBartender = (user) => {
  user.cash -= 5
  user.beverages = user.beverages || []
  user.beverages.push(`beer`)
  return user
}
*/

// we need map so that we can alter things within the Either value
const map = require(`ramda/src/map`)

// let's establish our basic expectations
const usersShouldBe21 = ({age}) => age > 20
const usersShouldHaveCashToCoverABeer = ({cash}) => cash - 5 >= 0

// and the errors we have
const warnYoungsters = (user) => `Expected ${user.name} to be 21!`
const warnWouldBeDebtors = (user) => `Expected ${user.name} to have at least 5 dollars!`

const cashAndAgeSafeBartender = pipe(
  // add safety for age!
  rail(usersShouldBe21, warnYoungsters),
  // add safety for cash!
  // multiRail is identical to rail, but should only be used when rail is already being used
  multiRail(usersShouldHaveCashToCoverABeer, warnWouldBeDebtors),
  // alter the Either value, so wrap our original function in `map`
  map(unscrupulousBartender),
  // convert our Either value to a string and print it
  logOrWarn
)

console.log(`=== example five ===`)
console.log(`jimmy is rejected for being underage:`)
cashAndAgeSafeBartender(jimmy)
// Expected jimmy to be 21!
console.log(`alice buys beer until she is broke:`)
cashAndAgeSafeBartender(alice)
// { name: 'alice', cash: 10, age: 22, beverages: [ 'beer' ] }
cashAndAgeSafeBartender(alice)
// { name: 'alice', cash: 5, age: 22, beverages: [ 'beer', 'beer' ] }
cashAndAgeSafeBartender(alice)
// { name: 'alice', cash: 0, age: 22, beverages: [ 'beer', 'beer', 'beer' ] }
cashAndAgeSafeBartender(alice)
// Expected alice to have at least 5 dollars!

Finally, to round it out, you can use guideRail to automate the above process:

const {guideRail} = require(`./handrail`)

const cashAndAgeSafeBartender2 = guideRail(
  [
    // add safety for age!
    [usersShouldBe21, warnYoungsters],
    // add safety for cash!
    [usersShouldHaveCashToCoverABeer, warnWouldBeDebtors]
    // add more!
  ],
  // alter the Either value
  unscrupulousBartender
)

Changelog

  • 1.0.0 - initial commit
  • 1.0.3 - added null safety
  • 1.0.4 - started using katsu-curry
  • 1.0.5 - added guideRail
  • 1.1.5 - reduced total size
  • 1.2.0 - modularized codebase
  • 1.3.0 - updated dependencies
  • 1.3.3 - fix exports
  • 1.3.4 - swap to jest, update speeds

API

handrail

Parameters

  • assertion function a function to test the input with
  • wrongPath function a function to prepare data before it passes into the Left path
  • rightPath function a function to modify after it passes into the Right path
  • input any any input

Returns (GuidedLeft | GuidedRight) an Either

rail

Add safety to your pipelines!

Parameters

  • assertion function boolean-returning function
  • wrongPath function function invoked if the inputs are bad
  • input any any input

Examples

import {rail} from 'handrail'
import pipe from 'ramda/src/pipe'
const divide = (a, b) => a / b
const safeDivide = curry((a, b) => pipe(
  rail(() => b !== 0, () => `Expected ${b} to not be zero!`),
  divide(a)
)(b)

Returns (GuidedRight | GuidedLeft) Left / Right -wrapped value

multiRail

multiRail is nearly-identical to rail, but should only be used if rail is already in use This is a useful function if you need very granular control of your pipe. If not, you should probably use guideRail instead.

Parameters

  • assertion function boolean-returning function
  • wrongPath function function invoked if the inputs are bad
  • input any any input

Examples

import {rail, multiRail} from 'handrail'
import pipe from 'ramda/src/pipe'
const divide = (a, b) => a / b
const safeDivide = curry((a, b) => pipe(
  rail(() => (typeof a === `number`), () => `Expected ${a} to be a number!`),
  multiRail(() => (typeof b === `number`), () => `Expected ${b} to be a number!`)
  multiRail(() => b !== 0, () => `Expected ${b} to not be zero!`),
  divide(a)
)(b)

Returns (GuidedRight | GuidedLeft) Left / Right -wrapped value

guideRail

Encapsulate error states in a simple structure that returns a Left on error or Right on success

Parameters

  • rails Array<functions> an array of [assertion, failCase] pairs
  • goodPath function what to do if things go well
  • input any whatever

Examples

import pipe from 'ramda/src/pipe'
import {guideRail, fold} from 'handrail'
const identity = (x) => x
const rails = [
  [({age}) => age > 20, ({name}) => `Expected ${name} to be 21.`],
  [({cash}) => cash - 5 >= 0, ({name}) => `Expected ${name} to have cash.`],
]
const bartender = (user) => {
  user.cash -= 5
  user.beverages = user.beverages || []
  user.beverages.push(`beer`)
  return user
}
const cashAndAgeSafeBartender = pipe(
  guideRail(rails, bartender),
  fold(identity, identity)
)

Returns (GuidedLeft | GuidedRight) an Either

bimap

Parameters

  • leftPath function do something if function receives a Left
  • rightPath function do something if function receives a Right
  • either Either either a Left or a Right

Returns Either the original Either, mapped over, but like, with handed-ness

fold

Parameters

  • leftPath function do something if function receives a Left
  • rightPath function do something if function receives a Right
  • either Either either a Left or a Right

Returns any the value from within an Either, pulled out of the monadic box