Abstract
Generator functions allow programmers to write monadic code for a monadic type of their choice, as opposed to async functions which only work for promises. Unfortunately, the generators returned by generator functions are mutable and can only be consumed once, which means that they can't be used to model non-deterministic monads such as arrays, parsers, etc. Thus, code for non-determinstic monads still results in callback hell. I propose we add a new syntax for monadic functions to the language. Monadic functions are like async functions, but they work for any data type whose values have a flatMap
method, such as arrays.
Background
Consider the following function which fetches a json document and returns an object that contains either the json data or the server response.
const fetchJSON = (url) => fetch(url).then((response) => {
const { ok } = response;
if (!ok) return { ok, response };
return response.json().then((data) => ({ ok, data }));
});
This code has nested then
callbacks, which is difficult to read and understand. But, modern JavaScript engines support the async…await
syntax, which makes it possible to write the above function without unnecessary nesting. This makes it much easier to read and understand.
const fetchJSON = async (url) => {
const response = await fetch(url);
const { ok } = response;
if (!ok) return { ok, response };
const data = await response.json();
return { ok, data };
};
The async…await
syntax allows us to compose functions which return promises without introducing unnecessary nesting. However, promises aren't the only type of data which supports this kind of composition. There are infinitely many data types which support this kind of composition. The option data type is one such example.
class Option {
constructor(option) {
this.option = option;
}
flatMap(arrow) {
const { option } = this;
return option === null ? this : arrow(option.value);
}
}
export const Some = (value) => new Option({ value });
export const None = new Option(null);
Consider the following code that uses the option data type. Notice that the parseAndExtract
function has nested flatMap
callbacks, which is difficult to read and understand, similar to the nested then
callbacks in the above fetchJSON
function.
import { Some, None } from "./option.js";
import { parseValue } from "./parseValue.js";
import { extractData } from "./extractData.js";
const parse = (input) => {
const value = parseValue(input);
return value === null ? None : Some(value);
};
const extract = (value) => {
try {
return Some(extractData(value));
} catch {
return None;
}
};
const parseAndExtract = (input) =>
parse(input).flatMap((value) =>
extract(value).flatMap((data) =>
Some({ value, data })));
Fortunately, modern JavaScript engines support generator functions, which makes it possible to write the parseAndExtract
function without unnecessary nesting.
const monadic = (generatorFunction) => (...someArguments) => {
const generator = generatorFunction(...someArguments);
const next = (data) => {
const result = generator.next(data);
return result.done ? result.value : result.value.flatMap(next);
};
return next(undefined);
};
const parseAndExtract = monadic(function* (input) {
const value = yield parse(input);
const data = yield extract(value);
return Some({ value, data });
});
As the name of the above monadic
function implies, it can be used for any data type that implements the monadic interface, i.e. any data type whose values have a valid flatMap
method. Unfortunately, that's not entirely true. Arrays implement the monadic interface, but the monadic
function breaks for arrays with more than one element.
const foo = monadic(function* (x) {
const y = yield [x * 1, x * 2, x * 3];
const z = yield [y + 1, y + 2, y + 3];
return z;
});
// Expected: [11, 12, 13, 21, 22, 23, 31, 32, 33]
// Actual: [11, undefined, undefined, undefined, undefined]
console.log(foo(10));
The expectation is that the above foo
function returns the same results as the following bar
function for the same inputs.
const bar = (x) =>
[x * 1, x * 2, x * 3].flatMap((y) =>
[y + 1, y + 2, y + 3].flatMap((z) => z));
However, this is not the case because generator functions return mutable generators which can only be consumed once. Hence, the only non-deterministic path which produces a result is when y = x * 1
and z = y + 1
. All the other non-deterministic paths run on a generator which has reached the end and can't produce more values. Hence, the rest of the array elements are all undefined
.
Proposal: Monadic Functions
To solve this problem I propose that we introduce new syntax for monadic functions in JavaScript. For example, the above foo
function could be written as follows.
const foo = monadic (x) => {
const y = yield [x * 1, x * 2, x * 3];
const z = yield [y + 1, y + 2, y + 3];
return z;
};
console.log(foo(10)); // [11, 12, 13, 21, 22, 23, 31, 32, 33]
Thus, instead of async…await
which only works for promises, we can now use monadic…yield
which works for any data type whose values have a flatMap
method. In fact, this syntax would just be syntactic sugar for flatMap
. Every yield m
expression could be converted to a m.flatMap
method call.
Of course, JavaScript being an untyped programming language would mean that we could mix and match different monadic types. We could also yield
or return
a non-monadic type. Whether that would throw an error or return some value, depends upon the code in question. However, for the most part I would expect it to behave like the monadic
helper function in the previous section.
For example, I would expect that the following function always returns the value 10
even though it's not actually a monadic value, i.e. it doesn't have a flatMap
method.
const baz = monadic () => 10;
console.log(baz()); // 10
On the other hand, I would expect the following function to return an array.
monadic function qux() {
yield Array.from({ length: 3 });
return 10;
};
console.log(qux()); // [10, 10, 10]
Why should it return an array? Because it can be de-sugared to the following code.
function qux() {
return Array.from({ length: 3 }).flatMap(() => 10);
}
Again, the rule of thumb is to replace every yield m
expression with a call to m.flatMap
.
Conclusion
Monads are a very useful abstraction to have in the language, which is why we have the async…await
syntax. However, this syntax is restricted to promises. It would be nice to have language level support for all monads, even user defined monads. I think that my proposal for monadic functions using the monadic…yield
syntax would have the least mental overhead for most programmers and also be an excellent addition to the language.