Return home

async is a good first monad

Posted 2022–03–12 as a longer form of a Hacker News comment.

Audience: programmers interested in functional programming, especially those who have used Promises and async/await in JavaScript. Not necessarily intended as a first introduction to monads.

Intro

People who are trying out Haskell quickly run into the concept of the monad, typically around the time they want their programs to actually do something. Two monadic types that new Haskell programmers will encounter are IO and Maybe. Having an understanding of how to use these is pretty important to writing useful code in Haskell. However, I don’t think these are necessarily great examples for introducing the concept.

IMO one of the best monads for explaining the concept is async promises, especially for JavaScript programmers who haven’t always had the luxury of async/await. The concept of asynchronous callbacks is deeply familiar to many JS programmers, and it conveniently illustrates the structure of monads.

Why async is a monad

If we have a long-running process, we can block the thread and wait for it to return us a result, but that’s far from ideal. Instead we return a “promise”, a structure that we can provide with a callback (using the then method on the promise) to manipulate the result of our asynchronous processing.

The types look something like this:
Promise<T1>.then(T1 => Promise<T2>): Promise<T2>
We call our async function, which gives us a Promise object wrapping a value of type T1. Our then method accepts a function from type T1 to type T2, and returns us a Promise wrapping a value of type T2

Let’s look at the type of monad bind, >>= in Haskell:
m a -> (a -> m b) -> m b
That is, bind accepts a Monad instance wrapping a value of type a, and a function from type a to type m b (our Monad type wrapping type b). It then returns us the m b that the function returns.

The types of bind and then are very similar! Promise is (approximately) a monad.

Why it matters

The fact that we can’t just “get” the result of the Promise and instead need to provide a callback is a core part of my understanding of the use of monads in Haskell — they are used in Haskell to represent the result of a non-deterministic effect. You can’t (or shouldn’t) just get the value out of a monad, in the same way that you can’t just get “the” value out of an array — an array has more than one value, so how can you get “the” value?

The use of monads to represent non-deterministic effects is a software engineering choice. The “monad” itself is just the structure of computation where you have a wrapped thing M a, to which you apply a “callback” a -> M b, to receive a monadic result M b. There are also some laws which a well-behaved monad needs to follow. The point of this is that even though that b might error out (option), or might come about later (promise), or might be multiple values (list), the M b can be treated as a plain-old function result and the type checker is able to make stronger guarantees. Pretty handy when your language is based around pure functions.

Side note: async/await is pretty much do-notation for Promises. You write imperative-looking code and it gets magically turned into asynchronous callbacks for you.

Why I think it’s a better first monad

IO and Maybe are both essential tools in any practical Haskell code. However, they each have their flaws as introductory monads.

IO is conceptually difficult, and the idea of codifying real-world interaction in the type system is likely to be unfamiliar to many programmers. In contrast, promises and async/await (or something similar) are used in a variety of popular programming langauges. Programmers are likely already somewhat familiar with how it works, and the idea of interacting with this effect through a monadic interface. This means one less thing to learn.

Maybe on the other hand is much simpler. Monad tutorials will often provide a simple implementation of it. However, I think it may be too simple. With async, it can be understood that you can’t just “get” the value, as it may not exist yet. In contrast, it may seem like interacting with Maybe through bind is unnecessary. Sometimes it is.

List is sometimes mentioned as well. It has a defined monad instance, and there are reasons why it is defined that way. I reckon it might be more likely to confuse, and raise questions like, “yeah, but WHY is it defined like that?”
That was my experience anyway. Worth looking at, but not first.