An experiment in bringing Haskell's programmable semicolon to JavaScript, using generators.
npm install --save burrido
import Monad from 'burrido'
const ArrayMonad = Monad({
pure: x => [x],
bind: (xs, f) => xs.map(f).reduce((a, b) => a.concat(b), [])
})
ArrayMonad.Do(function*() {
const x = yield [1,2]
const y = yield [3,4]
return x * y
}) // -> [3,4,6,8]
The above should look fairly self-explanatory to a Haskell programmer: we are declaring a Monad
instance for arrays, which requires us to define two functions: pure
and bind
. Then we obtain a special function Do
which is a do-notation tailored to that particular monad. We pass a generator function to Do
, within which we gain access to the yield
keyword, allowing us to "unwrap" monadic values and release their effects.
In fact this is a bit more versatile than Haskell's do-notation in a couple of interesting ways:
- Haskell's
Monad
is a type class, which means that there can only be one way in which a given type constructor is considered a monad within a given scope. But some type constructors can be considered monadic in more than one way (e.g.Either
). By contrast, here you can create as manyMonad
definitions as you want for a particular type (constructor), and each just has its own specialDo
function. - While
const foo = yield bar
is comparable to
foo <- bar
in do-notation, one can also create compound yield
expressions which have no direct analogue in Haskell. For example,
const foo = yield (yield bar)
would have to be written as
foo' <- bar
foo <- foo'
in do-notation. In the context of Do
blocks, yield
serves a similar purpose to the !
operator in both Idris and the Effectful library for Scala.
An example using RxJS
RxJS Observable
s form a monad in several different ways:
const { just: pure } = Observable
const { Do: doConcat } = Monad({
pure,
bind: (x, f) => x.concatMap(f)
})
const { Do: doMerge } = Monad({
pure,
bind: (x, f) => x.flatMap(f)
})
const { Do: doLatest } = Monad({
pure,
bind: (x, f) => x.flatMapLatest(f)
})
It's instructive to see what happens when you apply these different do-notations to the same generator block:
const { from } = Observable
const block = function*() {
// for each x in [1,2,3]...
const x = yield from([1,2,3])
// wait 1 second
yield pure({}).delay(1000)
// then return the value
return x
}
// Prints 1, 2, and 3 separated by 1 second intervals
doConcat(block).subscribe(console.log)
// Waits 1 second and then prints 1, 2, 3 all at once
doMerge(block).subscribe(console.log)
// Waits 1 second and then prints 3
doLatest(block).subscribe(console.log)
This should make sense if you think about the semantics of each of these different methods of "flattening" nested Observable
s. Each do*
flavor applies its own semantics to the provided block, but they all return Observable
s, so we can freely combine them:
doConcat(function*() {
const x = yield doConcat(function*() {
//...
}),
y = yield doMerge(function*() {
//...
}),
z = yield doLatest(function*() {
//...
})
return { x, y, z }
})
RxJS has a function spawn
which allows you to use this kind of syntax with Observable
s, but it only works properly with single-valued streams (essentially Promises), whereas burrido allows manipulating streams of multiple values, using multiple different semantics.
Because JavaScript's generators are neither immutable nor cloneable, we are forced to simulate immutable generators using a library called immutagen. There are two unavoidable downsides to this approach:
pure
andbind
must be side-effect free. This can always be achieved in practice by making the monadic type lazy (wrapping it in a closure). See this issue.- The simulation is inefficient, requiring O(n^2) steps to execute the nth yield within a single generator. See this issue.
With natively immutable generators (or the native ability to clone mutable generators), both of these limitations would go away.