Lifting functions into monadic context in JavaScript
Lifting functions into monadic context in JavaScript

Lifting functions into monadic context in JavaScript

Written on

Lifting functions into the monadic context of algebraic structures is a quite practical pattern. This article will prove its practicality, and when you finish reading it, you will have another FP tool to conquer the complexities of imperative code and create yet more elegant functional code. I will assume the reader's basic understanding of the concepts of functional programming and algebraic structures. I will use monet.js as a JavaScript implementation of a Monad algebraic structure and ramda as a functional library containing some nifty utils to manipulate Monads.

Let’s say we have a programming challenge. We want to add two numbers together to create another number. Well, it’s not really a challenge for most of you; it’s quite a primitive problem to solve.

1
2
// add :: (Number, Number) -> Number
const add = (a, b) => a + b;

Here, there it is. Quite simple. Same type of input and output. It is very easy to understand and reason with. In category theory we call this endomorphism. Only numbers should go into this function, and only numbers should come out.

Now we need some function to parse user input and run it through our add function. As it turns out, there is already such a function in JavaScript. It’s called parseInt.

1
2
3
4
5
const userInputA = '1';
const userInputB = '2';

const a = parseInt(userInputA, 10); //=> Number(1)
const b = parseInt(userInputB, 10); //=> Number(2)

But what if our user input contains some junk instead of numbers? We may end up with the following.

1
2
3
4
5
const userInputA = 'junk';
const userInputB = '2';

const a = parseInt(userInputA, 10); //=> NaN
const b = parseInt(userInputB, 10); //=> Number(2)

Houston, we have a problem! Constant a now contains NaN. In category theory, our function add can process input from the category Number. But NaN is not part of that category, so the behavior of add will be undefined.

Maybe monad to the rescue

Maybe is a monad that contains some value or nothing. Basically, it is a type-safe container for our parsed value. We can lift JavaScript’s parseInt function to return a Maybe monad.

1
2
3
4
5
6
7
8
9
10
11
12
13
const { Maybe } = require('monet');

const userInputA = 'junk';
const userInputB = '2';

// safeParseInt :: (Any, Number) -> Maybe Number
const safeParseInt = (value, radix = 10) => {
  const parsed = parseInt(value, radix);
  return isNaN(parsed) ? Maybe.Nothing() : Maybe.Some(parsed);
};

const a = safeParseInt(userInputA); //=> Maybe.Nothing()
const b = safeParseInt(userInputB); //=> Maybe.Some(2)

Now that we have our parsed values locked in type-safe containers, we need to add them together. But how do we do that? Actually, Maybe implements the Apply spec and therefore contains an ap method that allows us to combine multiple monads together and map them over a function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { curry } = require('ramda');
const { Maybe } = require('monet');

const userInputA = '1';
const userInputB = '2';

// safeParseInt :: (Any, Number) -> Maybe Number
const safeParseInt = (value, radix = 10) => {
  const parsed = parseInt(value, radix);
  return isNaN(parsed) ? Maybe.Nothing() : Maybe.Some(parsed);
};

const a = safeParseInt(userInputA); //=> Maybe.Some(1)
const b = safeParseInt(userInputB); //=> Maybe.Some(2)

// add :: (Number, Number) -> Number
const add = (a, b) => a + b;
// curried version of add
// addC :: Number -> Number -> Number
const addC = curry(add);

const added = a.ap(b.map(add)); //=> Maybe.Some(3)

This seems quite ugly and impractical. Let us create a better version of this code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { liftFN } = require('ramda-adjunct');
const { Maybe } = require('monet');

const userInputA = '1';
const userInputB = '2';

// safeParseInt :: (Any, Number) -> Maybe Number
const safeParseInt = (value, radix = 10) => {
  const parsed = parseInt(value, radix);
  return isNaN(parsed) ? Maybe.Nothing() : Maybe.Some(parsed);
};

const a = safeParseInt(userInputA); //=> Maybe.Some(1)
const b = safeParseInt(userInputB); //=> Maybe.Some(2)

// addM :: Apply Number => Number -> Number -> Number
const addM = liftFN(2, add);

const added = addM(a, b); //=> Maybe.Some(3)

What have we just done? Well, we used liftFN from ramda-adjunct. It takes an arity and a function and returns a homomorphic, autocurried function lifted into monadic context with the specified arity. Or in layman’s terms: it takes the inputs as monads, unwraps the values from the monads, applies them to the original function, wraps the result into a compatible monad, and returns it. This is what I call heavy lifting.

Insight into heavy lifting

Ramda also contains a liftN util. Why didn’t we use it? Well, there’s a thing called the fantasy-land specification. This specification tells us how to implement algebraic structures so that they can be interoperable. Ramda <= 0.23.0 implements an obsolete version of this spec, and that is why it is not compatible with monet.js. Today I have released liftFN, which acts as an interop for Ramda <= 0.23.0 and monet.js <= 0.8.1. The next version of Ramda will once again be compatible with the fantasy-land specification, and the next version of monet.js will also be compatible with the fantasy-land spec thanks to PR #112, making it compatible with Ramda. For the time being, use liftFN from ramda-adjunct to circumvent this inconvenience.

This is the topmost level the elevator goes today ;] Remember to write pure, testable functions that work with JavaScript’s native types, and lift them later into monadic context (if you need to). And compose, compose, compose…!


Fork me on GitHub