Managing side effects with monads in JavaScript
Instead of explaining the laws governing monads and explaining that monad is just a monoid in the category of endofunctors, we will focus on the practical application of monads with @7urtle/lambda.
To clarify things you can understand monads as wrappers that aim to simplify the dealing with side effects, errors, inputs, outputs, and asynchronous code. You can also see them as containers around values or functions.
As you probably know, functional programming is very particular about keeping your functions pure dependent only on their inputs and not causing any external effects. But real life usually requires your code to read data from somewhere and then save or display some results. Among side effects, you can count output into console, reading files, updating database, editing DOM, saving data, and so on. For all of these you can use monads.
In @7urtle/lambda we have Maybe, Either, Case, SyncEffect, and AsyncEffect. All of these are functors, monads, and applicatives at the same time because they implement methods map, flatMap, and ap.
map
Every functor has the method map
. map
allows you to apply a function on a value that the functor wraps around safely. It's all easier to understand on an example:
1import {deepInspect, map} from '@7urtle/lambda';23// we define our own functor Functor4const Functor = {5of: value => ({6value: value,7inspect: () => `Functor(${deepInspect(value)})`,8map: fn => Functor.of(fn(value))9})10};1112const add1 = a => a + 1;1314// we wrap Functor around the value 115const myFunctor = Functor.of(1); // Functor(1)16myFunctor.inspect(); // => 'Functor(1)'1718// we map the function add1 on the value inside the functor19const myFunctorPlus1 = myFunctor.map(add1); // Functor(2)2021myFunctorPlus1.value; // => 222myFunctor.map(a => a + 1).value; // => 223myFunctor.value; // => 1, no mutation2425// you can also apply function map on a functor with the same result26myFunctorPlus1again = map(add1)(myFunctor);27myFunctorPlus1again.value === myFunctorPlus1.value; // => true2829// it is the same map that we use for arrays30map(add1)([1, 2]); // => [2, 3]
Now if you understand the example and how the function map
works, you now understand the basics of functors in functional programming. Specialized monads then add a special magic to these and you can learn about that later when those are discussed.
flatMap
If you had a function that returns a functor and you mapped the function on a functor, you would end up with a functor inside of a functor. Every monad has the method flatMap
. flatMap
allows you to map a function the same as map
but it expects that the mapped function returns the same type of monad as is the type of the monad that we are mapping it on. It deals with it by flattening the result into a simple value inside of a monad instead of a value in a monad in a monad. Here is an example:
1import {deepInspect, flatMap} from '@7urtle/lambda';23// we define our own monad Monad4const Monad = {5of: value => ({6value: value,7inspect: () => `Monad(${deepInspect(value)})`,8map: fn => Monad.of(fn(value)),9flatMap: fn => fn(value)10})11};1213// we wrap Monad around the value 114const myMonad = Monad.of(1); // Monad(1)1516const add1Monad = a => Monad.of(a + 1);1718// we map the function add1 on the value inside the monad19myMonad.map(add1Monad); // Monad(Monad(2));20const myMonadPlus1 = myMonad.flatMap(add1Monad); // Monad(2)2122myMonadPlus1.value; // => 223myMonad.value; // => 1, no mutation2425// you can also apply function flatMap on a monad with the same result26myMonadPlus1again = map(add1Monad)(myMonad);27myMonadPlus1again === myMonadPlus1; // => true
Now if you understand the example and how the function flatMap
works, you now understand the basics of monads in functional programming.
ap
Because in functional programming we also love partial application, we have applicatives and their method ap
. ap
allows you to map curried functions that require multiple values using map
and apply those values using ap
. More in the following example:
1import {deepInspect} from '@7urtle/lambda';23// we define our own applicative Applicative4const Applicative = {5of: value => ({6value: value,7inspect: () => `Applicative(${deepInspect(value)})`,8map: fn => Applicative.of(fn(value)),9flatMap: fn => fn(value),10ap: f => f.map(value)11})12};1314// we wrap Applicative around the value 115const myApplicative = Applicative.of(1); // Applicative(1)1617const add = a => b => a + b;1819// we map the function add on the applicative20const myApplicativePartial = myApplicative.map(add); // Applicative(1 + b);21const myApplicativePlus1 = myApplicativePartial.ap(Applicative.of(1)); // Applicative(2)22const myApplicativePlus3 = myApplicativePartial.ap(Applicative.of(3)); // Applicative(4)2324myApplicativePlus1.value; // => 225myApplicative.value; // => 1, no mutation2627// you can also apply function flatMap on a monad with the same result28myMonadPlus1again = map(add1Monad)(myMonad);29myMonadPlus1again === myMonadPlus1; // => true
Now if you understand the example and how the function ap
works, you now understand the basics of applicatives in functional programming.
@7urtle/lambda also gives you functions liftA2 and liftA3 to simplify writing functions that work with applicatives. It takes a regular function that expects simple values and turns it into a function that supports applicatives:
1import {liftA2} from '@7urtle/lambda';23const add = a => b => a + b;45const addAp = liftA2(add);67myResultApplicative = addAp(Applicative.of(2))(Applicative.of(3));8myResultApplicative.value; // => 5
Composing with monads
You are still working with functional programming paradigm and your code should be based on composing pure functions. Consider monads as new language types or containers. Your functions should use monads as their outputs and you should prefer to create functions that partially apply map or flatMap rather than call these functions directly on a monad. Here are some examples:
1import {Maybe, map, flatMap, compose} from '@7urtle/lambda';23// the correct way4const getElement = selector => Maybe.of(document.querySelector(selector));5const getClientHeight = map(a => a.clientHeight); // partial application of map6const getDOMHeight = compose(getClientHeight, getElement);78getDOMHeight('#someElement');910// the working but not recommended ways1112// breaks single responsibility and impacts reusability13const getDOMHeight2 = selector => Maybe.of(document.querySelector(selector)).map(a => a.clientHeight);1415// doesn't use functions, instead uses monads as statement or OOP objects16Maybe17.of(document.querySelector('#someElement'))18.map(a => a.clientHeight);
This approach will help you build more reusable code following general programming best practices.
Now all that remains is the magic of specific monads in @7urtle/lambda.
Maybe there is a value
Maybe is the most simple monad to learn. Maybe expects to maybe get a value as its input. It evaluates whether the value is Nothing (empty, null, or undefined) or Just. In other languages, Maybe monad can also be called Option monad or Nullable monad.
1import {Maybe, maybe, isNothing, sJust} from '@7urtle/lambda';23Maybe.of('some value'); // Just('some value');4Maybe.of(''); // Nothing5Maybe.of([]); // Nothing6Maybe.of({}); // Nothing7Maybe.of(null); // Nothing8Maybe.of(undefined); // Nothing910Maybe.of(5).isJust(); // => true11Maybe.of(5).value; // => 512Maybe.of([]).isNothing(); // => true13Maybe.of([]).value; // => []1415// function maybe is there to help you process the result16const testMaybe =17maybe18('Maybe is Nothing')19(value => 'Maybe is: ' + value);2021testMaybe(Maybe.of([])); // => 'Maybe is Nothing'22testMaybe(Maybe.of('something')); // => 'Maybe is: something'2324// @7urtle/lambda also provides independent isNothing and isJust functions25isNothing(''); // => true26isJust(''); // => false
Maybe allows you to safely map over its value as the mapped function is applied only if the value is Just.
1import {Maybe} from '@7urtle/lambda';23Maybe.of(5).map(a => a + 5); // Just(10)4Maybe.of(undefined).map(a => a + 5); // Nothing56Maybe.of(5).flatMap(a => Maybe.of(a + 5)); // Just(10)7Maybe.of(undefined).flatMap(a => Maybe.of(a + 5)); // Nothing89Maybe.of(5).map(a => b => a + b).ap(Maybe.of(5)); // Just(10)10Maybe.of(undefined).map(a => b => a + b).ap(Maybe.of(5)); // Nothing
Your own function should return Maybe in cases that you are unsure that there is an output usually in when you are working with side effects. For example you can use it like this when working with DOM:
1import {Maybe, map, compose} from '@7urtle/lambda';23// returns DOM element or undefined if the element #imayexist doesn't exist4document.querySelector('#imayxist');56// returns position from top or throws and error if the element doesn't exist7document.querySelector('#imayxist').offsetTop;89// Let's try it with Maybe without any worry about throwing errors10const selectMaybeDOM = selector => Maybe.of(document.querySelector(selector));11const getPositionFromTop = element => element.offsetTop;12const getMaybePositionFromTopDOM = compose(map(getPositionFromTop), selectMaybeDOM);1314// or just one line (which goes against single responsibility principle)15const getMaybePositionFromTopDOM2 = selector => Maybe.of(document.querySelector(selector)).map(a => a.offsetTop));1617const MaybeResult = getMaybePositionFromTopDOM('#imayexist');18MaybeResult.isNothing() ? 'Element does not exist' : 'I have result ' + MaybeResult.value;
Either it is Success or Failure
Either is another simple monad to learn. Either represents either Success or Failure of execution to allow you to manage error states in your code. For your convenience there is also a function try
to help with try/catchfor functions in nodejs that may throw an error.
1import {Either, either} from '@7urtle/lambda';23Either.of('ok'); // Success('ok')4Either.of('ok').value; // => 'ok'56Either.Success('ok'); // Success('ok')7Either.Failure('error'); // Failure('error')89const iThrowError = () => {10throw new Error('I am an error.');11};1213Either.try(() => 'ok'); // Success('ok')14Either.try(iThrowError); // Failure('I am an error.')1516// function either is there to help you process the result17const testEither =18either19(error => 'There is an error: ' + error)20(value => 'Your result is: ' + value);2122testEither(Either.Success('ok')); // => 'Your result is: ok'23testEither(Either.Failure('error')); // => 'There is an error: error'
Either allows you to safely map over its value as the mapped function is applied only if the value is Success.
1import {Either} from '@7urtle/lambda';23Either.of(5).map(a => a + 5); // Success(10)4Either.Failure(5).map(a => a + 5); // Failure(5)56Either.of(5).flatMap(a => Either.of(a + 5)); // Success(10)7Either.Failure('error').flatMap(a => Either.of(a + 5)); // Failure('error')89Either.of(5).map(a => b => a + b).ap(Either.of(5)); // Success(10)10Either.Failure('error').map(a => b => a + b).ap(Either.of(5)); // Failure('error')
Your own function should return Either to express that there is some error usually in a connection with working with side effects. Consider this example of working with nodejs reading a file:
1import {Either, either} from '@7urtle/lambda';2import fs from 'fs';34const readFileEither = file => Either.try(fs.readFileSync(file));56const data = readFileEither('i/may/not/exist.txt');78data.isFailure() ? 'there is an error ' + data.value : 'your data ' + data.value;
Or consider an example of working with DOM where querySelector throws an error if its selector has the wrong format:
1import {Either, isNothing, flatMap, compose} from '@7urtle/lambda';23document.querySelector('@'); // Uncaught DOMException: Failed to execute 'querySelector' on 'Document': '@' is not a valid selector.45const querySelectorEither = selector => Either.try(() => document.querySelector(selector));6const getClientHeightEither = flatMap(value => isNothing(value) ? Either.Failure('Element does not exist.') : Either.Success(value.clientHeight));7const selectHeightEither = compose(getClientHeightEither, querySelectorEither);89selectHeightEither('@'); // Failure('Uncaught DOMException: Failed to execute 'querySelector' on 'Document': '@' is not a valid selector.')10selectHeightEither('#idontexist'); // Failure('Element does not exist.')11selectHeightEither('#iexist'); // Success(333);1213const testEither =14either15(error => 'There is an error: ' + error)16(value => 'Your result is: ' + value);1718testEither(selectHeightEither('#iexist')); // => 'Your result is: 333'
It depends on the Case
Case is a monad representing a simple map with a matching mechanism. You can use use it as a replacement for a switch with monad enhancements.
1import {Case} from '@7urtle/lambda';23// Case expect key-value pairs as its input4const myCase = Case.of([5[1, 'one'],6['key', 'value'],7['_', 'fallback'] // '_' represents default fallback option8]);910// you reach a value by matching keys using Case.match11myCase.match(1); // => 'one'12myCase.match('key'); // => 'value'13myCase.match('nope'); // => 'fallback'1415// if no fallback is defined and no key is matched, we return undefined16Case.of([]).match('nope'); // => undefined
Case allows you to safely map over its value as the mapped function is applied only after match
is called.
1import {Case, upperCaseOf, liftA2} from '@7urtle/lambda';23Case.of([['key', 'value']]).map(upperCaseOf).match('key'); // => 'VALUE'45// you can merge Cases together by using flatMap6Case.of([[1, 'I am']]).flatMap(a => Case.of([[1, a + ' a turtle']]).match(1); // => 'I am a turtle'78// case is also an applicative so you can use partial application9const add = a => b => a + b;10liftA2(add)(Case.of([[1, 1]]))(Case.of([[1, 2]])).match(1); // => 3
Trigger dangerous SyncEffect lazily
SyncEffect is a wrapper for dangerous functions adding lazy evaluation through its trigger. That means that the wrapped around function is not executed until a trigger is called. SyncEffect serves as a monad to signal that there is some potential dangerous side effect that is not handled. SyncEffect can be called the IO monad in other languages or frameworks. The use of the monad is quite simple:
1import {SyncEffect} from '@7urtle/lambda';23// we create SyncEffect that expects a number from 0 to 14// and based on that, it returns a value or throws an error5const throwError = () => {throw 'random failure'};6const dangerousFunction = value => value > 0.5 ? 'random success' : throwError();7const mySyncEffect = SyncEffect.of(dangerousFunction);89// when you are ready, you can call trigger to trigger the side effect10// nothing is executed until the trigger is called11// if you pass value to a trigger it is used a an argument for the wrapped around function12mySyncEffect.trigger(Math.random());13// => returns 'random success' or throws 'random failure' depending on Math.random() value
SyncEffect allows you to safely map over its value as the mapped function is applied only after trigger
is called.
1import {SyncEffect, upperCaseOf, liftA2} from '@7urtle/lambda';23const throwError = () => {throw 'random failure'};4const dangerousFunction = value => value > 0.5 ? 'random success' : throwError();5const mySyncEffect = SyncEffect.of(dangerousFunction);67// as a functor the value inside is safely mappable8// map doesn't execute in case of an error and nothing executes until a trigger is called9mySyncEffect10.map(value => upperCaseOf(value))11.trigger(Math.random());12// => returns 'RANDOM SUCCESS' or throws 'random failure' depending on Math.random() value1314// as a monad SyncEffect can be safely flat mapped with other SyncEffects15// flatMap doesn't execute in case of an error and nothing executes until a trigger is called16SyncEffect.of(() => '7turtle').flatMap(a => SyncEffect.of(() => a + 's')).trigger();1718// as an applicative functor you can apply SyncEffects to each other especially using liftA2 or liftA319const add = a => b => a + b;20liftA2(add)(SyncEffect.of(() => 1)(SyncEffect.of(() => 2)).trigger(); // => 321SyncEffect.of(() => add).ap(SyncEffect.of(() => 1)).ap(SyncEffect.of(() => 2)).trigger(); // => 3
You can use SyncEffect in a similar situations as the monad Either. The difference is that you are deferring handling the potential negative effects.
1import {SyncEffect, liftA2, Either} from '@7urtle/lambda';23const getElementHeightSyncEffect = selector => SyncEffect.of(document.querySelector(selector).clientHeight);4getElementHeightSyncEffect('#iexist'); // => 3335getElementHeightSyncEffect('#idontexist'); // Uncaught TypeError: Cannot read property 'clientHeight' of null67const addAp = LiftA2(a => b => a + b);89addAp(getElementHeightSyncEffect('@'))(getElementHeightSyncEffect('#iexist')).trigger();10// 'Uncaught DOMException: Failed to execute 'querySelector' on 'Document': '@' is not a valid selector.'1112you can still use the monad Either to handle the potential error state13const getElementHeightEither = selector => Either.try(getElementHeightSyncEffect(selector).trigger);14getElementHeightEither('@').isFailure(); // => true
Master lazy asynchronous AsyncEffect
AsyncEffect is a wrapper for dangerous asynchronous functions adding lazy evaluation through its trigger. That means that the wrapped around function is not executed until a trigger is called. AsyncEffect is similar to Promise because its trigger also expects reject and resolve functions that are called based on AsyncEffect execution. Reject function is also called if there is an error thrown. AsyncEffect can also be called Future monad in other libraries or languages.
1import {AsyncEffect, log} from '@7urtle/lambda';23// we create AsyncEffect that expects a number from 0 to 14// and based on that, it resolve or rejects 10 milliseconds after it is triggered5const myAsyncEffect = AsyncEffect6.of(reject => resolve =>7setTimeout(() => Math.random() > 0.5 ? resolve('random success') : reject('random failure'), 10)8);910// when you are ready, you can call trigger to trigger the side effect11// nothing is executed until the trigger is called12myAsyncEffect13.trigger14(error => log(error))15(result => log(result));16// => logs 'random success' or 'random failure' depending on Math.random() value1718// you can also turn AsyncEffect into a JavaScript Promise19myAsyncEffect20.promise()21.then(result => log(result), error => log(error));22// => logs 'random success' or 'random failure' depending on Math.random() value2324// thrown exceptions lead AsyncEffect to reject25AsyncEffect26.of(() => {27throw 'error';28})29.trigger(log)(log);30// => logs 'error'
AsyncEffect allows you to safely map over its value as the mapped function is applied only after trigger
is called.
1import {AsyncEffect, upperCaseOf, liftA2} from '@7urtle/lambda';23const myAsyncEffect = AsyncEffect4.of(reject => resolve =>5setTimeout(() => Math.random() > 0.5 ? resolve('random success') : reject('random failure'), 10)6);78// alternatively you could make it from an existing JavaScript Promise9const myPromise = new Promise((resolve, reject) =>10setTimeout(() => Math.random() > 0.5 ? resolve('random success') : reject('random failure'), 10)11);12const promiseAsyncEffect = AsyncEffect.ofPromise(myPromise);1314// as a functor the value inside is safely mappable15// map doesn't execute in case of an error and nothing executes until a trigger is called16myAsyncEffect17.map(value => upperCaseOf(value))18.trigger(log)(log);19// => logs 'RANDOM SUCCESS' or 'random failure' depending on Math.random() value2021// as a monad AsyncEffect can be safely flat mapped with other AsyncEffects22// flatMap doesn't execute in case of an error and nothing executes until a trigger is called23AsyncEffect24.of(reject => resolve => resolve('7urtles'))25.flatMap(a => AsyncEffect.of(reject => resolve => resolve(a + 's')))26.trigger(log)(log);27// => logs '7urtles'2829// as an applicative functor you can apply AsyncEffects to each other especially using liftA2 or liftA330const add = a => b => a + b;31const AS1 = AsyncEffect.of(reject => resolve => resolve(1));32const AS2 = AsyncEffect.of(reject => resolve => resolve(2));33liftA2(add)(AS1)(AS2); // => resolve(3)
You can use AsyncEffect as an elegant wrapper around your ajax calls or work asynchronously with file reading/writing:
1import {AsyncEffect} from '@7urtle/lambda';2import axios from 'axios';3import fs from 'fs';45// axios example6const getFromURL = url => AsyncEffect.ofPromise(axios.get(url));78getFromURL('/my/ajax/url')9.trigger10(error => log(error))11(result => log(result.data));1213// reading file example14const readFile => input =>15AsyncEffect16.of(reject => resolve =>17fs.readFile(input, (err, data) =>18err ? reject(err) : resolve(data)19)20);2122readFile('./file.txt')23.trigger24(error => log(error))25(result => log(result));
Ask For Help
If you have any questions or need any help, please leave a message on @7urtle/lambda GitHub Issues to get quick assistance. If you find any errors in @7urtle/lambda or on the website please reach out.