If you look at how the continuation monad is defined in Haskell, it looks like this:
data Cont r a = Cont { runCont :: (a -> r) -> r }
By itself, this is completely pure, and doesn't represent real world effects or time. Even in its current form it can be used to represent temporal/IO effects, simply by choosing r
to be a type involving IO
. For our purposes however, we're going to do something slightly different. We're going to substitute the concrete ->
with a type parameter:
data Cont p r a = Cont { runCont :: p (p a r) r }
What is the use of this? In Haskell, we only have pure functions which map some input domain to an output domain. In other languages, we can have such functions, but we can additionally define impure "functions", which (in addition to producing some arbitrary output for a given input), may implicitly perform side effects.
Here is an example of both in JS:
// :: Int -> Int -> Int
const add = x => y => x + y
// :: String -!-> ()
const log = msg => { console.log(msg); }
Note that log
is not a pure function that produces a value representing an effect, which is how such things are encoded in pure languages like Haskell. Instead, the effect is associated with the mere invocation of log
. To capture this, we can use different arrows when we talk about pure functions and impure "functions" (->
and -!->
, respectively).
So, returning to your question about how the continuation monad solves callback hell, it turns out that (in JavaScript at least), most of the APIs that give rise to callback hell can be massaged quite easily into values of the form Cont (-!->) () a
, which I'll henceforth refer to as Cont! a
.
Once you have a monadic API, the rest is easy; you can traverse structures full of continuations into continuations of a structure, use do notation to write multi-step computations akin to async/await
, use monad transformers to equip continuations with additional behavior (for example error handling), etc.
The monad instance looks much the same as it does in Haskell:
// :: type Cont p r a = p (p a r) r
// :: type Cont! = Cont (-!->) ()
// :: type Monad m = { pure: x -> m x, bind: (a -> m b) -> m a -> m b }
// :: Monad Cont!
const Cont = (() => {
// :: x -> Cont! x
const pure = x => cb => cb(x)
// :: (a -> Cont! b) -> Cont! a -> Cont! b
const bind = amb => ma => cb => ma(a => amb(a)(cb))
return { pure, bind }
})()
Here are a couple of examples of modelling the setTimeout
and readFile
APIs available in Node JS as impure continuations:
// :: FilePath -> Cont! (Either ReadFileError Buffer)
const readFile = path => cb => fs.readFile(path, (e, b) => cb(e ? Left(e) : Right(b)))
// :: Int -> v -> Cont! v
const setTimeout = delay => v => cb => setTimeout(() => cb(v), delay)
As a contrived example, here's the "callback hell" we enter when we use the standard APIs to read a file, wait five seconds, then read another file:
fs.readFile("foo.txt", (e, b1) => {
if (e) { throw e }
setTimeout(() => {
fs.readFile("bar.txt", (e, b2) => {
if (e) { throw e }
console.log(b1.toString("utf8") + b2.toString("utf8"))
})
}, 5000)
})
And here is the equivalent program using the continuation monad:
const ECont = EitherT(Cont)
const decode = buf => buf.toString("utf8")
const foo = readFile("foo.txt") |> ECont.map(decode)
const bar = readFile("bar.txt") |> ECont.map(decode)
// Imaginary do notation for JS for purposes of clarity
const result = do(ECont)([
[s1, foo],
ECont.lift(delay_(5000)),
[s2, bar],
ECont.pure(s1 + s2)
])
const panic = e => { throw e }
const log = v => { console.log(v) }
// No side effects actually happen until the next line is invoked
result(Either.match({ Left: panic, Right: log }))