A library should support your decisions while allowing you to leave an impression for others to follow.
I made this little library of helper functions to aid my development of javascript/typescript applications and learn more about the functional paradigm.
npm install @ryandur/sand
A Result is either a success or a failure. Every operation on it says which side it cares about, so the
happy path chains without a single null check, and the failure rides along untouched until you decide what to
do with it.
import {success, failure} from '@ryandur/sand';
success(2)
.map(value => value + 1)
.mBind(value => value > 0 ? success(value) : failure('negative'))
.orElse(0); // produces: 3
failure<string, number>('boom')
.map(value => value + 1) // never runs
.orNull(); // produces: null
When both sides deserve an answer, fold the two branches into one value with either.
success<number, string>(2).either(value => `got ${value}`, reason => `oops, ${reason}`); // produces: "got 2"
The types ride along with the chain: mBind unions in any new error type it introduces, or unions in any
new value type a recovery introduces, and nothing ever widens to unknown.
A Maybe is either some thing or nothing. The maybe factory decides for you: null, undefined, and
NaN become nothing, anything else becomes some.
import {maybe, some, nothing} from '@ryandur/sand';
maybe('something').map(value => value + ' more').orNull(); // produces: "something more"
maybe(null).map(value => value + ' more').orNull(); // produces: null
maybe(NaN).orElse('fallback'); // produces: "fallback"
some(1).and(some(2)).map(([one, two]) => one + two).orNull(); // produces: 3
nothing().or(() => some('recovered')).orNull(); // produces: "recovered"
A Result.Async lets you work with a promise the same way you work with a Result, plus onPending for
loading states. The story below shows it in action.
Every chain is cancelable: cancel silences all the chain's consumers (no more setState-after-unmount),
and onCancel attaches work to free — the creator of the work is the only one who knows how to stop it,
so it hangs that knowledge on the chain (requesting does this to abort its in-flight exchange). Explicit
reads still settle honestly; cancel suppresses pushes, it never corrupts pulls. The shape is made for
effect cleanup:
useEffect(() => getArt(id).onPending(isLoading).onSuccess(updatePiece).cancel, [id]);
When the moment comes to leave the container, no new piece is needed: value hands you the settled
Result, and Result.either is the true fold. The exit composes from what you already have — and since
the value doesn't exist until the promise does, it arrives inside a Promise. Like every explicit read
it is a pull: cancel suppresses pushes, it never corrupts pulls.
const view = (await getArt(id).value).either(
art => ({state: 'loaded', art}),
reason => ({state: 'failed', reason})
); // one value, whichever branch the exchange took
Array.reduce lifted over the container, living on the type it reduces. reduce.all demands every item
land; reduce.some forgives the ones that don't and keeps the reductions that do. Either way, a failure
from the reducer itself fails the whole fold.
import {Maybe, Result, asyncSuccess, failure, nothing, some, success} from '@ryandur/sand';
Result.reduce.all([success(1), success(2), success(3)], (total, value) => success(total + value), success(0))
.orNull(); // produces: 6
Maybe.reduce.all([some(1), nothing(), some(3)], (total, value) => some(total + value), some(0))
.orNull(); // produces: null (all needs every one)
Result.reduce.some([success<number, string>(1), failure('x'), success(3)], (total, value) => success(total + value), success(0))
.orNull(); // produces: 4 (some skips the failure)
await Result.Async.reduce.all([asyncSuccess<number, string>(1), asyncSuccess<number, string>(2)], (total, value) => asyncSuccess(total + value), asyncSuccess(0))
.orNull(); // produces: 3
Both guard code that throws, folding the throw into the failure side so a chain never needs its own
try block. You say how a thrown unknown becomes your error type; toError is the everyday mapper.
import {tryCatch, asyncTryCatch, toError} from '@ryandur/sand';
tryCatch(() => JSON.parse('{"name": "sand"}'), toError)
.map(parsed => parsed.name)
.orElse('unknown'); // produces: "sand"
tryCatch(() => JSON.parse('not json'), toError)
.orElse('unknown'); // produces: "unknown" — the throw became a failure
await asyncTryCatch(() => fetch('/thing'), toError)
.onFailure(explain)
.orNull(); // guards the synchronous throw AND the rejection
requesting guards the whole HTTP exchange — every verb, not just the gets. A body means stringify it and say
it's JSON (your own Content-Type wins if you name one); no body means nothing is invented. The
response comes back whole — status policy stays with the caller, where it belongs. Bring no signal and
the chain's cancel aborts the exchange at the network layer; bring your own and it stays yours.
import {requesting} from '@ryandur/sand';
requesting('/things', {method: 'POST', body: {name: 'sand'}}, () => AnError.NETWORK)
.mBind(response => response.ok ? bodyOf(response) : explain(response));
connecting is requesting's sibling for sockets: it guards the handshake — the one exchange a socket
settles — and hands the open connection back whole. Message policy stays with the caller, where it
belongs. The chain owns the teardown: cancel closes the socket, whether the handshake is still in
flight or long since landed, and a socket that closes before it ever opens folds into the failure side.
import {connecting} from '@ryandur/sand';
connecting('wss://example.test/live', () => 'unreachable')
.onSuccess(socket => socket.addEventListener('message', receive));
useEffect(() => connecting(url, explain).onSuccess(listen).cancel, [url]);
The engines under reduce are public, and they consume structure, not names. foldAll folds anything
whose items chain with mBind (a Monad); foldSome forgives the misses of anything that can fold its
two branches (a Catamorphism). Mint a type with those capabilities and the engines are yours — nothing
to register, the structure is the membership.
import {Catamorphism, Functor, Monad, foldAll} from '@ryandur/sand';
interface Counted extends Functor<number, Counted>, Monad<number, Counted>, Catamorphism<number, Counted> {
count: number;
}
const counted = (count: number): Counted => ({
count,
map: f => counted(f(count)),
mBind: f => f(count),
either: onHit => onHit(count)
});
foldAll([counted(1), counted(2), counted(3)], (total, value) => counted(total + value), counted(0))
.count; // produces: 6
And prove you joined the algebra correctly: lawsOf hands back the functor and monad laws as runnable
assertions — the same harness the shipped containers prove themselves with in their own suites.
import {lawsOf} from '@ryandur/sand';
const laws = lawsOf<Counted, void>(counted, (left, right) => expect(left.count).toEqual(right.count));
laws.leftIdentity(); // unit(a).mBind(f) is f(a)
laws.rightIdentity(counted(3)); // m.mBind(unit) is m
laws.associativity(counted(3)); // the order of binding never matters
laws.mapIdentity(counted(3)); // mapping identity changes nothing
laws.mapComposition(counted(3)); // mapping f then g is mapping g after f
Let's look at how we might use this lib. Imagine we are creating an art gallery, and we want to take a closer look at one of the pieces. To get the piece of art we send a request to a backend referencing it by id. The response might take a little while, so we need a way to notify the user that the request is pending. Once we have obtained the piece we will need to display it, or if the call has failed we need to notify that something went wrong.
In the example below we request the art via ID. While we wait, we notify the user that the content is loading. The onPending function will fire the provided callback twice. Once when it is invoked and again once the call is done, passing the pending state (true then false) as a parameter. Once the call is complete it will invoke either onSuccess with the data or onFailure with a possible explanation.
getArt(id)
.onPending(isLoading)
.onSuccess(updatePiece)
.onFailure(hasErrored);
To handle the request, we make a http GET to the endpoint with the id. We validate the response, if the response is structured correctly pass back the successful response, else pass back a failure with some explanation.
getArt: (id: string): Result.Async<Art, AnError> =>
http.get(`/some-endpoint/${id}`)
.mBind(response => maybe(valid(response))
.map(asyncSuccess)
.orElse(asyncFailure({type: Problem.CANNOT_DECODE, cause: response})))
To make the request, we fetch from the endpoint. If there is some kind of network error we give back an explanation. If successful, we check the response status. Since it's a GET we expect a 200 is a successful response, or we consider it a failure. Then we get the JSON out of the response. If there is a problem with the JSON we pack it into an explanation.
get: (endpoint: string): Result.Async<Art, AnError> =>
asyncResult(fetch(endpoint))
.or(err => asyncFailure({type: Problem.NETWORK_ERROR, cause: err}))
.mBind(response => response.status === HTTPStatus.OK
? asyncResult(response.json())
: asyncFailure({type: Problem.NOT_OK, cause: response}));