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:
-
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()
-
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}
- 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