Fantas, Eel, and Specification 13: Chain
15 May 2017You told me to leave you alone. My Papa said, “Come on home”. My doctor said, “Take it easy”, but your lovin’ is much too strong. I’m added to your… Chain
, Chain
, Chain
! Maybe we didn’t compose that one, but we’re going to compose
plenty of things today!
Let’s take a moment to recap a few previous posts. When we covered Functor
, we saw that functors created contexts in which our “language” could be extended:
a :: a -> [b] -- Multiple results
m :: a -> Maybe b -- Possible failure
e :: a -> Either e b -- Possible exception
t :: a -> Task e b -- Async action
We’ve also seen that Applicative
functors’ contexts can be combined to give us cool tricks like parallel Task
actions and Traversable
:
// Just([-1, -2, -3])
;[1, 2, 3].traverse(Maybe, x => Just(-x))
// Logs 50 (after only 2000ms!)
lift2(
x => y => x + y,
new Task((_, res) =>
setTimeout(
() => res(20),
2000)),
new Task((_, res) =>
setTimeout(
() => res(30),
1000)))
.fork(
_ => console.error('???'),
x => console.log(x))
There’s one thing we can’t do, though. What if we want to compose
two Functor
-returning functions? Take a look at this example:
//+ prop :: String -> StrMap a -> Maybe a
const prop = k => xs =>
k in xs ? Just(xs[k])
: Nothing
const data = { a: { b: { c: 2 } } }
const map = f => xs => xs.map(f)
// How do we get to the 2?
prop('a')(data) // Just({ b: { c: 2 } })
.map(prop('b')) // Just(Just({ c: 2 }))
.map(map(prop('c'))) // Just(Just(Just(2)))
// And if we fail?
prop('a')(data) // Just({ b: { c: 2 }})
.map(prop('badger')) // Just(Nothing)
.map(map(prop('c'))) // Just(Nothing)
So, we can get to the 2
, but not without a lot of mess. We map
more and more each time, when all we really want is a Just 2
if it works and a Nothing
if it doesn’t. What we’re missing is a way to flatten layers of Maybe
. Enter join
:
//+ join :: Maybe (Maybe a) ~> Maybe a
Maybe.prototype.join = function () {
return this.cata({
Just: x => x,
Nothing: () => Nothing
})
}
prop('a')(data) // Just({ b: { c: 2 } })
.map(prop('b')).join() // Just({ c: 2 })
.map(prop('c')).join() // Just(2)
prop('a')(data) // Just({ b: { c: 2 } })
.map(prop('badger')).join() // Nothing
.map(prop('c')).join() // Nothing
We seem to have solved our problem! Each time we map
with a Maybe
-returning function, we call join
to flatten the two Maybe
layers into one. map
, join
, map
, join
, and so on. Back in the Traversable
post, we saw that the map
/sequence
pattern was common enough that we gave it an alias: traverse
. Wouldn’t you know it, the same applies here: the map
/join
pattern is so common, we call it chain
.
//+ chain :: Maybe a ~> (a -> Maybe b)
//+ -> Maybe b
Maybe.prototype.chain = function (f) {
return this.cata({
Just: f,
Nothing: () => this // Do nothing
})
}
// Just like `sequence` is `traverse` with
// `id`, `join` is `chain` with `id`!
//+ join :: Chain m => m (m a) ~> m a
const join = xs => xs.chain(x => x)
// Our example one more time...
prop('a')(data) // Just({ b: { c: 2 } })
.chain(prop('b')) // Just({ c: 2 })
.chain(prop('c')) // Just(2)
So many parallels! Now, this fancy little pattern is useful far beyond an occasional Maybe
. It turns out that we can unlock a lot of behaviour this way:
//+ chain :: Either e a
//+ ~> (a -> Either e b)
//+ -> Either e b
Either.prototype.chain = function (f) {
return this.cata({
Right: f,
Left: _ => this // Do nothing
})
}
const sqrt = x => x < 0
? Left('Hey, no!')
: Right(Math.sqrt(x))
Right(16)
.chain(sqrt) // Right(4)
.chain(sqrt) // Right(2)
Right(81)
.chain(sqrt) // Right(9)
.map(x => -x) // Right(-9) 😮
.chain(sqrt) // Left('Hey, no!')
.map(x => -x) // Left('Hey, no!')
Left('eep')
.chain(sqrt) // Left('eep')
Just as Maybe
’s chain
would carry forward a Nothing
, Either
’s will carry forward the first Left
. We can then chain a bunch of Either
-returning functions and get the first Left
if anything goes wrong - just like throwing exceptions! With Either
, we can model exceptions in a completely pure way. Pure. Savour this moment. We fixed exceptions.
This one’s exciting, right? Let’s see what Array
can do:
//+ chain :: Array a
//+ ~> (a -> Array b)
//+ -> Array b
Array.prototype.chain = function (f) {
// Map, then concat the results.
return [].concat(... this.map(f))
}
// NB: **totally** made up.
const flights = {
ATL: ['LAX', 'DFW'],
ORD: ['DEN'],
LAX: ['JFK', 'ATL'],
DEN: ['ATL', 'ORD', 'DFW'],
JFK: ['LAX', 'DEN']
}
//- Where can I go from airport X?
//+ whereNext :: String -> [String]
const whereNext = x => flights[x] || []
// JFK, ATL
whereNext('LAX')
// LAX, DEN, LAX, DFW
.chain(whereNext)
// JFK, ATL, ATL, ORD, DFW, JFK, ATL
.chain(whereNext)
With Array
, we map
with some Array
-returning function, then flatten all the results into one list. map
and join
, just like everything else! Here, we’re essentially traversing a graph, and building up a list of possible positions after each step. In fact, languages like PureScript use chain
to model loops*:
//- An ugly implementation for range.
//+ range :: (Int, Int) -> [Int]
const range = (from, to) =>
[... Array(to - from)]
.map((_, i) => i + from)
//- The example from that link in JS.
//+ factors :: Int -> [Pair Int Int]
const factors = n =>
range(1, n).chain(a =>
range(1, a).chain(b =>
a * b !== n
? []
: [ Pair(a, b) ]))
// (4, 5), (2, 10)
factors(20)
Now, let’s talk about our Task
type†. We’ve seen that, with its Apply
implementation, we get parallelism for free. However, we haven’t actually talked about how to get serial behaviour. For example, what if we wanted to do some AJAX to get a user, and then use the result to get their friends? Well, chain
would appear to be exactly what we need:
//+ getUser :: String -> Task e Int
const getUser = email => new Task(
(rej, res) => API.getUser(email)
.map(x => x.id)
.then(res)
.catch(rej))
//+ getFriendsOf :: Int -> Task e [User]
const getFriends = id => new Task(
(rej, res) => API.getFriends(id)
.then(res)
.catch(rej))
// Task e [User] - hooray?
getUser('test@test.com').chain(getFriends)
It turns out this behaviour is defined on our Task
type, so we can chain
together Task
objects to get sequencing when we need it, and ap
them together for free parallelism. Before we get too excited, though, let’s talk about what’s wrong here.
If a type lawfully implements Chain
(and hence Apply
and Functor
), we can derive ap
using chain
and map
:
//+ ap :: Chain m => m a ~> m (a -> b) -> m b
MyType.prototype.ap = function (fs) {
return fs.chain(f => this.map(f))
}
So what? Well, this ap
doesn’t behave like the ap
we already have. Most importantly, there’s no parallelism! Houston, we have a problem. In the case of Task
, either the ap
or chain
method is therefore unlawful.
There have been similar discussions that are worth reading for more background, but this point is quite an advanced discussion, so brace yourself.
This means that, to write law-obiding code, we need to accept that either our ap
method is wrong, or our chain
method shouldn’t exist. Personally, I’ve always chosen to view chain
as the “problem method”, and asserted that Task
is an Applicative
but not a Chain
. What we should do is convert our Task
to some “sequential” type when we want to do something sequential:
// A "sequential" async type.
const Promise = require('fantasy-promises')
// For "errors" within Promise.
const { Left, Right }
= require('fantasy-eithers')
//- Convert a Task to a Promise
//+ taskToPromise :: Task e a
//+ -> Promise (Either e a)
const taskToPromise = task => Promise(
res => task.fork(e => res(Left(e)),
x => res(Right(x))))
//+ promiseToTask :: Promise (Either e a)
//+ -> Task e a
const promiseToTask = promise =>
new Task((rej, res) =>
promise.fork(either =>
either.cata({
Left: rej,
Right: res
})
)
)
//- Finally...
//+ andThen :: Task e a ~> (a -> Task e b)
//+ -> Task e b
Task.prototype.andThen = function (f) {
return promiseToTask(
taskToPromise(this)
.chain(either => either.cata({
// We "lift" failure using Promise'
// Applicative instance.
Left: _ => Promise.of(either),
Right: x => taskToPromise(f(x))
}))
)
}
//- ... which gives us:
getUser('test@test.com')
.andThen(getFriends)
Big and scary, but don’t panic. Here, we’re taking advantage of the natural transformation between Task
and the composition of Promise
and Either e
in both directions: we move to a sequential context, do something sequential, then move back to the parallel context.
With promiseToTask
and taskToPromise
, we can convert any Promise
into a Task
and vice versa. This is exactly what we need in order to say that Promise
and Task
are isomorphic!
Isomorphisms come up a lot to avoid these problems: if we can “convert” a type into another type with a required capability, and then convert it back, it’s as good as having that capability in our original type! The difference is, however, that we’re not breaking the laws.
Of course, you could just as easily go ahead and use chain
, and accept that it is badly-behaved. That’s cool, as long as you’re aware of this, and know to expect some unexpected results. You could also write a simple implementation of andThen
:
//+ No intermediate type!
Task.prototype.andThen = function (f) {
return new Task((rej, res) =>
this.fork(rej, x =>
f(x).fork(rej, res))
)
}
It’s really down to you, but the lengthier method means that we neatly separate our parallel and sequential types, and can know what behaviour is expected in each. Task
’s author wrote a blog post on async control, which may shed more light here.
We’ve seen what chain
does: map
then join
. This is why you’ll hear it called flatMap
sometimes: it maps
and then “flattens” two layers of Chain
type into one. You might also hear concatMap
(named after Array
’s particular implementation) or bind
.
We’ve also seen that we can define ap
in terms of chain
in a way that will work for any law-obiding type. If you don’t believe me, try it with Maybe
, Either
, or Array
! This, however, isn’t the only rule that we have to follow. There’s one, very familiar, law that comes with this class:
// Associativity.
m.chain(f).chain(g)
=== m.chain(x => f(x).chain(g))
// Remember Semigroups?
a.concat(b).concat(c)
=== a.concat(b.concat(c))
Just as Semigroup
was associative at value-level, and Apply
was associative at context-level, we can see that Chain
seems to be associative at an order-level: unlike Apply
, which we saw would freely allow for parallelism, Chain
implies order. Just look at the type of chain
:
chain :: Chain m => m a
~> (a -> m b)
-> m b
Our chain
function is useless until we know what the a
in m a
is. How can we chain
a function on a Promise
until the first part has completed? How can we chain
a function on a Maybe
until we know whether the first part failed? With chain
, we finally have a way to encode order into our programs, and strictly forbid parallelism when we need to.
I think it’s amazing that we managed to achieve all that we have so far without this concept! We’re so used to order being determined by the ordering of the lines in our file, but that’s because of state: we can’t mix those lines up because they may depend on one another.
With purely-functional code, we know that can’t be true, and that all dependencies are explicit. Who cares which side of an ap
gets calculated first? Who cares whether a map
happens now or later? Until we fork
or extract
a value from a Functor
, it’s just not important - it’s an implementation detail!
A note to end on: for Semigroup
, we had Monoid
, which brought the empty value. For Apply
, we had Applicative
, which brought the pure context. Seems logical that Chain
would have a similar partner, right? We’ll get to it in a fortnight.
Until then, mess around with this post’s Gist, and chain
all the things! With parallel and sequential power, you actually now have everything you need to write any app in an entirely pure manner. Check out the fantasy-io
library to see how we can encode any IO in a Chain
type and completely remove the need for state. Go on: I dare you!
♥
* do
notation really just connects each line with a chain
call (or bind
, as it’s known in Haskell/PureScript). Here’s more information on do
, but I wouldn’t worry too much at this stage.
† Hat-tip to @safareli, who reminded me to include this bit!