Monadic Functions

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.

3 Likes

Reminded me of GitHub - pelotom/immutagen: A library for simulating immutable generators in JavaScript which emulates a type of cloneable generator by replaying the generator from the start each time it forks, with the exponential performance issue being the natural consequence.

1 Like

Ah, yes. I'm one of the contributors of the immutagen library. I fixed the deterministic monad replay bug, and made some quality of life changes to the library.

I also wrote a Stack Overflow Q&A on implementing Haskell's do notation in JavaScript, if you're interested in reading about it.

1 Like

The use-case presented is to "flatten nested code caused by using monadic data types". The example solution presented is very similar to other functional languages, but I wonder if there's alternative syntax we could explore that might be more friendly for those who are less familiar with the scary "monad" word :).

For example, what if you could put an "..." inside any location where a block body is expected, and that means "make the rest of this outer block be the contents of this block? That's confusing to say, so perhaps a few examples makes it clearer.

// How we could write it today
function processData(data) {
  const result = [];
  if (data !== undefined) {
    for (const entry of data) {
      for (const subEntry of entry) {
        data.push(subEntry.map(value => {
          return value + 1;
        }));
      }
    }
  }
  return result;
}

// How we could write it with the `...` syntax
function processData(data) {
  const result = [];
  if (data !== undefined) {
    for (const entry of data) { ... }
    for (const subEntry of entry) { ... }
    data.push(subEntry.map(value => { ... }));
    return value + 1;
  }
  return result;
}

// Other examples from the O.P. showing how to solve the monadic use-case

const foo = monadic (x) => {
  [x * 1, x * 2, x * 3].flatMap(y => { ... });
  [y + 1, y + 2, y + 3].flatMap(x => { ... });
  return z;
};

const fetchJSON = (url) => {
  fetch(url).then((response) => { ... });
  const { ok } = response;
  if (!ok) return { ok, response };
  return response.json().then((data) => { ... });
  return { ok, data };
};

Notice how we can solve the same use-case, and more, with a syntax like this, and it's much easier for beginners to pick up and understand as well.

Now, this specific syntax has some problems of it's own that we could pick at. The main one I see is: I find it really difficult to track what variables are available to me - now I'm not just looking for const/let declarations, I also have to pay attention to whatever variables that got introduced in the middle of an expression, that were exposed to a { ... } block, like what happens with x => { ... }.

With some refinement, there may be ways to fix this issue. For example, maybe a line that uses the special { ... } block has to also put something at the beginning of the line to visually mark the block, to help train our eyes to remember that something special is happening on that line, perhaps like this:

const fetchJSON = (url) => {
  > fetch(url).then((response) => { ... });
  const { ok } = response;
  if (!ok) return { ok, response };
  > return response.json().then((data) => { ... });
  return { ok, data };
};

(that specific syntax example will, I'm sure, be a major ASI issue, but I'm ignoring that for now)

Or maybe you specify which variables you want to be added to the scope in that marker, like this:

const fetchJSON = (url) => {
  const response from fetch(url).then((response) => { ... })
  const { ok } = response;
  if (!ok) return { ok, response };
  const data from return response.json().then((data) => { ... });
  return { ok, data };
};

Dunno, something to play with. The point is that, even though this classic monad-style syntax being presented is very common among functional languages, I'm not actually sure that that's the best way to solve the problem for a not-as-functional-language where many users aren't as familiar with the concept of a monad.

I'm open to using friendlier keywords instead of monadic and yield. For example, after thinking for a while I came up with the keywords flat instead of monadic and map! instead of yield.

const foo = flat (x) => {
  const y = map! [x * 1, x * 2, x * 3];
  const z = map! [y + 1, y + 2, y + 3];
  return z;
};

Note: Since map is a commonly used identifier, I didn't want to make it a keyword. Hence, I put an exclamation mark after map to make the keyword map!. I like this because the exclamation mark denotes side effects, kind of like the command syntax proposed by @mlanza.

Anyway, these functions can be called flat functions, which is a lot friendlier than calling them monadic functions. In addition, monadic functions could imply "functions which have only one input", which could be confusing for beginners.

It also makes sense to use flat and map! because m.flatMap(f) should be equivalent to m.map(f).flat(). This should make it easier to explain to beginners and it should also make it easier to remember that map! m expressions de-sugar to m.flatMap method calls.

Flat functions allow you to use map! m expressions, and then they flatten the results.

I don't buy that this syntax would be difficult for JavaScript programmers to learn for two reasons.

  1. JavaScript programmers are already familiar with the async…await syntax, which is the same thing as flat…map! except that it's specialized for promises. An await p expression can be de-sugared to a p.then method call. Similarly, a map! m expression can be de-sugared to an m.flatMap method call. This shouldn't be too difficult for JavaScript programmers to grok.
  2. JavaScript programmers don't need to understand the concept of a monad in order to use flat functions. They already know what the map method does, what the flat method does, and what the flatMap method does. We can explain the syntax using these three operations on arrays, without ever having to use the word "monad". And once they understand the syntax as it operates for arrays, then we can generalize and show that this syntax works for any data type whose values have a flatMap method.
1 Like

First, I think I found a mistake in your code. I believe that in your processData function you intended to write result.push instead of data.push.

function processData(data) {
  const result = [];
  if (data !== undefined) {
    for (const entry of data) {
      for (const subEntry of entry) {
        result.push(subEntry.map(value => {
          return value + 1;
        }));
      }
    }
  }
  return result;
}

Second, I'm not a fan of the syntax you proposed. I would much prefer writing the processData function in a monadic style as follows instead.

flat function processData(data) {
  if (data === undefined) return [];
  const entry = map! data;
  const subEntry = map! entry;
  return [subEntry.map((value) => value + 1)];
}

// is equivalent to

function processData(data) {
  if (data === undefined) return [];
  return data.flatMap((entry) =>
    entry.flatMap((subEntry) =>
      [subEntry.map((value) => value + 1)]));
}

Third, the syntax you proposed doesn't seem principled. For example, what would happen if I wrote an expression with multiple ellipses?

const fetchJSON = (url) => {
  fetch(url).then((response) => { ... }, (error) => { ... });
  // Is this the response branch? Or is this the error branch?
};

Fourth, the syntax you proposed is incredibly difficult to refactor. Consider your processData function for example.

function processData(data) {
  const result = [];
  if (data !== undefined) {
    for (const entry of data) {
      for (const subEntry of entry) {
        result.push(subEntry.map(value => {
          return value + 1;
        }));
      }
      console.log(result); // added a new line for debugging
    }
  }
  return result;
}

Notice that I added a new line inside the outer for loop. This makes it impossible to flatten the inner for loop using the syntax that you proposed. So, if I had initially flattened the function using the syntax that you proposed, then it would be impossible for me to refactor the code to add this line without unflattening the inner for loop first.

The syntax you're proposing is getting more and more complicated. I fail to see how this would be easier to explain to beginners than the flat…map! syntax that I proposed.

1 Like

Thanks for the catch

Syntax error.

What would happen if there's two map! on the same line?

const result = [map! value1, map! value2];

In functional languages, the map! is usually part of the assignment syntax, i.e. you must always place map! after =. Should we use the same restriction here? If we do, it does make map! a little more different from yeild.

Fourth, the syntax you proposed is incredibly difficult to refactor. Consider your processData function for example.

This is also true for the monadic syntax you proposed.

If I have

flat function processData(data) {
  if (data === undefined) return [];
  const entry = map! data;
  const subEntry = map! entry;
  return [subEntry.map((value) => value + 1)];
}

and I want to add a new line for debugging in a specific place, I'll be required to unflatten the code.

function processData(data) {
  if (data === undefined) return [];
  return data.flatMap(entry => {
    const res = entry.flatMap(subEntry => {
      return [subEntry.map((value) => value + 1)];
    });
    console.log(res); // added a new line for debugging
    return res;
  });
}

And, although less common, this is also true with async/await syntax.

If I have

async function doThing() {
  const data = someStuff();
  await task1(data);
  return someOtherStuff(data);
}

and I want to add logging to a specific place, I may have to unflatten it.

function doThing() {
  const data = someStuff();
  const promise = task1(data)
    .then(() => {
      return someOtherStuff(data);
    });
  console.log('task1() started successfully');
  return promise;
}

(This usually doesn't happen for logging purposes, but unflattening does happen for other reasons - sometimes you just need to run stuff after the first async task has kicked off)


As a separate question, how are you thinking of handling map! inside, say, a loop, or an "if"? Not allowed? Or does it apply the flattening logic to that specific block (the way I was doing with { ... })?

Anyways, you don't have to like my earlier { ... } idea, that's fine :) - but just want to point out that these specific issues apply to the proposal you're presenting as well, and is something that'll need to be worked out.

1 Like

It would be the same as if we'd used async…await or function*…yield.

async function main() {
  const result = [await 1, await 2];
  console.log(result); // [1, 2]
}

This would be equivalent to the following.

async function main() {
  const a = await 1;
  const b = await 2;
  const result = [a, b];
  console.log(result); // [1, 2]
}

Theory

Consider the expression 5 * <number>, where <number> denotes a hole which can be filled with a number. For example, we can fill the hole with the number 6 to get the expression 5 * 6.

Now, if we focus on the hole then the rest of the expression becomes the context of the hole. There's only one hole in this expression, so the context of the hole is called the "one-hole context". However, you can have multiple holes in an expression.

Anyway, the context of a hole can be represented as a continuation and continuations can be represented as functions. For example, the context of the hole in the expression 5 * <number> is the function (x) => 5 * x.

Things get a little more complex when you have multiple holes. For example, consider the expression <number> * <number>. There are two holes here. We can either fill the left hole first, e.g. 5 * <number>, or we can fill the right hole first, e.g. <number> * 6.

Depending upon which hole we fill first, we get two different two-hole contexts.

  1. (x) => (y) => x * y
  2. (y) => (x) => x * y.

The point is that we're forced to choose an order. Now, the order in which we fill the holes doesn't matter here because this is a pure expression. However, in the case of most monads the order does matter. You can think of flatMap as an operation which fills the hole in one-hold context.

In lazy languages like Haskell, there's no predefined order of evaluation. For example, given the expression [2 + 3, 5 * 6] in Haskell there's no guarantee that 2 + 3 will be evaluated before 5 * 6 or vice-versa. This is why Haskell forces programmers to pick an order of evaluation in the do-notation by making <- part of the assignment syntax.

However, JavaScript is a strict language. Given the expression [2 + 3, 5 * 6] we know that 2 + 3 will be evaluated before 5 * 6. Because of this, we know the order of evaluation of [map! value1, map! value2]. Here, value1 will be evaluated before value2; and value2 may be evaluated multiple times if value1 produces multiple values, or it might not be evaluated at all if value1 doesn't produce any values.

Anyway, the point is that having multiple map! expressions in a single expression or statement is not a problem. On the other hand, as you mentioned having multiple { ... } blocks in a single expression or statement is a syntax error.

Why is this the case? It all comes down to contexts and holes. A map! m expression can be thought of as a hole which is filled by values from the expression m. For example, in the expression 5 * map! [1, 2, 3] the hole map! [1, 2, 3] is filled by the values 1, 2, and 3 respectively to get the expressions 5 * 1, 5 * 2, and 5 * 3.

On the other hand, in the expression fetch(url).then((response) => { ... }) the hole ... is filled by the rest of the statements in the current block. Thus, it's not possible to have multiple holes. Otherwise, there's a conflict. As you mentioned, that's the reason why multiple holes is a syntax error.

Anyway, that's the reason why I prefer the flat…map! syntax over { ... } blocks. It makes more sense intuitively.

I concede. This is indeed also true for the monadic syntax. I actually though that this wouldn't be true for the monadic syntax because of the monad associativity law.

m.flatMap(k).flatMap(h) ≡ m.flatMap((x) => k(x).flatMap(h))

However, the monad associativity law doesn't apply here. Thanks for pointing it out.

Excellent question. Both await and yield expressions can produce at most one value. Hence, they don't have to worry about producing multiple values. However, map! can produce multiple values. So, what should be the behavior inside of if statements and loops?

Let's start with if statements because those are easier. Consider the following function.

flat function foo() {
  let x = map! [1, 2, 3];
  if (x !== 2) {
    const y = map! [4, 5, 6];
    x = x + y;
  }
  return x;
}

The question is, what should foo return? Well, we'll just consider all the pairs of x and y.

  1. x = 1, y = 4, result = x + y = 5
  2. x = 1, y = 5, result = x + y = 6
  3. x = 1, y = 6, result = x + y = 7
  4. x = 2, result = x = 2
  5. x = 3, y = 4, result = x + y = 7
  6. x = 3, y = 5, result = x + y = 8
  7. x = 3, y = 6, result = x + y = 9

So, calling foo() returns the array [5, 6, 7, 2, 7, 8, 9].

What about loops? Consider the following program.

flat function bar() {
  let x = 0;
  for (let i = 0; i < 3; i++) {
    const y = map! [i, i + 1, i + 2];
    x = x + y;
  }
  return x;
}

Again, the question is what should bar return? This code gets confusing very quickly, but we can follow along step by step.

  1. x = 0
  2. i = 0
  3. y = map! [0, 1, 2]
    • y = 0
      1. x = x + y = 0
      2. i = 1
      3. y = map! [1, 2, 3]
        • y = 1
          1. x = x + y = 1
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 3
              2. i = 3
            • y = 3
              1. x = x + y = 4
              2. i = 3
            • y = 4
              1. x = x + y = 5
              2. i = 3
        • y = 2
          1. x = x + y = 2
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 4
              2. i = 3
            • y = 3
              1. x = x + y = 5
              2. i = 3
            • y = 4
              1. x = x + y = 6
              2. i = 3
        • y = 3
          1. x = x + y = 3
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 5
              2. i = 3
            • y = 3
              1. x = x + y = 6
              2. i = 3
            • y = 4
              1. x = x + y = 7
              2. i = 3
    • y = 1
      1. x = x + y = 1
      2. i = 1
      3. y = map! [1, 2, 3]
        • y = 1
          1. x = x + y = 2
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 4
              2. i = 3
            • y = 3
              1. x = x + y = 5
              2. i = 3
            • y = 4
              1. x = x + y = 6
              2. i = 3
        • y = 2
          1. x = x + y = 3
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 5
              2. i = 3
            • y = 3
              1. x = x + y = 6
              2. i = 3
            • y = 4
              1. x = x + y = 7
              2. i = 3
        • y = 3
          1. x = x + y = 4
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 6
              2. i = 3
            • y = 3
              1. x = x + y = 7
              2. i = 3
            • y = 4
              1. x = x + y = 8
              2. i = 3
    • y = 2
      1. x = x + y = 2
      2. i = 1
      3. y = map! [1, 2, 3]
        • y = 1
          1. x = x + y = 3
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 5
              2. i = 3
            • y = 3
              1. x = x + y = 6
              2. i = 3
            • y = 4
              1. x = x + y = 7
              2. i = 3
        • y = 2
          1. x = x + y = 4
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 6
              2. i = 3
            • y = 3
              1. x = x + y = 7
              2. i = 3
            • y = 4
              1. x = x + y = 8
              2. i = 3
        • y = 3
          1. x = x + y = 5
          2. i = 2
          3. y = map! [2, 3, 4]
            • y = 2
              1. x = x + y = 7
              2. i = 3
            • y = 3
              1. x = x + y = 8
              2. i = 3
            • y = 4
              1. x = x + y = 9
              2. i = 3

So, calling bar() returns the following array.

[
   3, 4, 5,
   4, 5, 6,
   5, 6, 7,

   4, 5, 6,
   5, 6, 7,
   6, 7, 8,

   5, 6, 7,
   6, 7, 8,
   7, 8, 9,
]

As you can see, every time we encounter the map! expression at runtime we can select one of multiple non-deterministic paths. We select each of those paths in order. The resulting code has exponential time complexity, so you need to be careful.

So, using map! inside if statements and loops is indeed allowed. It also doesn't flatten just the code block. Instead, it non-deterministically selects a value for the rest of the computation. The rest of the computation includes the rest of the iterations in the loop, as well as the rest of the function. I agree that it's a little difficult to wrap your head around, but it's a very useful abstraction. Just think of it in terms of contexts and holes, and everything should make sense.

No, I'm glad that you posted your idea. It made me think of the proposal more concretely. And thank you for asking all the questions that you did. I really appreciate it because it helped me flesh out the idea in my mind. Hopefully, I've been able to answer all your questions satisfactorily.

1 Like

Hmm, ok. I think I see how this fits together. I'm curious now about the finally block - I'm trying to logic how this fits with the proposal:

flat function(arr) {
  setThingsUp();
  try {
    const x = map! arr;
    moreLogic();
  } finally {
    console.log('finally!');
  }
}

If I pass in an empty array, would it be true that the finally block never executes? While an array with two items will cause the finally block to execute twice? What about if moreLogic() always throws, will the finally block execute exactly once even if there's multiple items in the array?

I'm going to start asking some weirder questions now, mostly because I'm curious. The answers to these are probably not as important because it would be uncommon for real code to hit these kinds of scenarios.

What if the array I pass in isn't a normal array, instead it's a special array with a fancy flatMap() method which just throws an error instead of calling it's calback. Would the error originate from the "flat!" operator, and cause the finally block to execute once?

What if the special array has a mostly normal flatMap() except at the point when it would normally return a result, instead, it throws. If the special array had two entries, would the finally block execute three times? Once for each entry, but also for the error that originates from "flat!"?

1 Like

Monads are not a powerful enough abstraction to support try blocks. So, the short answer is that you can't use try blocks inside flat functions.

The long answer is that you'd need a catch method to implement try blocks, which is why in Haskell we have the MonadCatch type class. It's also the reason why promises in JavaScript have a catch method.

However, monads with a catch method are a strict subset of all monads. That means that there are some monads, such as arrays, which don't (and indeed can't) have a catch method. This means that if we want flat functions to work for all monads, i.e. the lowest common denominator, then we can't have try blocks within flat functions.

Instead, if a data type supports the catch or finally methods then we can call those methods manually within the flat function.

flat function foo(arr) {
  setThingsUp();
  map! arr
    // if flatMap itself throws an error then finally won't be called
    .flatMap((x) => moreLogic())
    .finally(() => {
      console.log("finally");
    });
}

We could, some time in the future, extend this proposal to support try blocks within flat functions. At that time, we'll have to square with how to differentiate between monads which have a catch method and monads which do not. We'll also have to deal with how to de-sugar try blocks, because it's not obvious how to do so.

At any rate, we shouldn't have to worry about try blocks right now. They are out of the scope of this proposal at the moment. Encountering a try block within a flat function should throw a syntax error for now. Later on, if we do start supporting try blocks within flat functions then we can change this behavior.

If the monad in question is an array, then you can't have a try block at all because arrays don't have a finally method. However, let's assume that you have a monad like ListT IO which does have a finally method, and can produce zero, one, or many values.

flat function foo(listIO) {
  setThingsUp();
  try {
    const x = map! listIO;
    moreLogic();
  } finally {
    console.log("finally");
  }
}

This would be desugared into something like the following.

function foo(listIO) {
  setThingsUp();
  let result;
  const finallyBlock = () => {
    console.log("finally");
  };
  try {
    result = listIO.flatMap((x) => moreLogic());
  } catch (error) {
    finallyBlock();
    throw error;
  }
  return result.finally(finallyBlock);
}

As you can see, the finally block is outside the flatMap. Hence, it doesn't matter whether listIO produces zero, one, or many values. The finally block will be executed exactly once.

However, because the IO monad allows asynchronous operations, you could potentially run into another problem. What if an IO operation never returns, or returns after a very long time? For example, suppose you set a timeout with a duration of 10 years. This means that the finally block will not be executed until after 10 years. However, after 10 years if the result is an empty list then the finally block will still be executed.

Now, suppose you run an IO operation which never returns. Then, the finally block will never be executed at all. We can't distinguish between an operation that never returns and an operation which takes a very long time to return. Hence, we can never execute the finally block.

Again, we wouldn't be talking about arrays in this case because arrays don't have a catch method. Instead, we would be talking about a data type like ListT IO. Anyway, there are two places where an error can be thrown.

  1. The callback function given to flatMap as an input throws an error when called.
  2. The flatMap function itself throws an error, possibly due to an incorrect implementation.

In the first case, the expectation is that flatMap would assimilate the error into the return value. It may stop on the first error, and simply return a value containing the error. Or, it may continue evaluating the rest of the non-deterministic paths and aggregating all the errors into an AggregateError, in which case it will return a value containing the AggregateError.

Regardless of the behavior of flatMap, the finally block will only be executed once because the finally method is chained after flatMap.

In the second case, the flatMap method itself throws an error due to a possibly incorrect implementation. In this case, we should indeed catch the error and execute the finally block. The error might occur after several non-deterministic paths have been executed. That's all right. The finally block will only be executed once.

2 Likes

@theScottyJam I'm building a babel plugin for flat functions. I'll make the repository open-source and I'll post the link here once I start. Developing this babel plugin will not only help me make the proposal more concrete, but also others will be able to play with the new feature and give more constructive feedback once it's done.

Just an idea: maybe macros on the language level can be used as a vehicle to propagate flat monadic syntax and the use of monads in general. Folks could also use it for infix function syntax, list comprehension and all sorts of extended syntaxes.

Would sacrifice the specificity/simplicity of your suggestion, though, which perfectly aligns to the familiar async/await syntax.

1 Like

Sounds interesting. We'd need some sort of hygienic macro system like we have in Racket. Any ideas on what it could look like in ES?

A natural benchmark would be Clojure's implementation since the lang is influenced by Lisp/FP. Have never used it but read mixed judgments. As functional programmers, we could even write some decent optimization macros, thus mitigating some performance penalties of FP idioms.

I wonder if async/await could have been implemented as a macro as well.

Concrete syntax has an advantage that it can't be dynamically patched, this makes it slightly easier to optimise for at runtime. An code can replace Promise.prototype.then so engines have to check that it hasn't been modified before assuming it is the original.