Function composition typically looks like this:
let x = 1;
f(g(h(x)));
This works as long as the result of h(1)
is an acceptable parameter to g
and if the results of g(h(1))
is an acceptable parameter to f
.
To make this more concrete let's define these functions:
function f(num) { return num * 2; }
function g(num) { return num + 1; }
function h(num) { return num * 10; }
So what if instead of n number, x
was a Promise<number>
?
let x = new Promise(function (resolve, reject) {
resolve(1);
});
We can't pass x directly to h
anymore. So instead we use then
to compose these functions:
x.then(h).then(g).then(f);
You'll notice that each call to then
produces a new Promise
with the function applied to the number "inside" the Promise
. So after calling x.then(h)
the value inside the Promise
is 10
.
This is exactly how map
on any monad works in Scala (replacing Promise
with Future
):
val x = Future(1)
x.map(h).map(g).map(f)
Back in javascript, what if one of the functions we're calling also produces a Promise
?
function h(num) {
return new Promise(function (resolve, reject) {
resolve(num * 10);
});
}
Well it turns out you still just call then
in javascript:
x.then(h).then(g).then(f)
Since Scala isn't quite as dynamic as javascript you have to introduce flatMap
to accomplish the same thing:
def h(num) = Future(num * 10)
x.flatMap(h).map(g).map(f)
If you used map
instead of flatMap
to call h
you end up with a nested type (since h
returns a Future[Int]
):
val y: Future[Future[Int]] = x.map(h)
This helps provide a sort of intuition of what flatMap
does: It applies the operation and then flattens the result from a Future[Future[Int]]
into a Future[Int]
.
For Promise/Future this makes sense. In JS the alternative is callback hell of the olden days, right?
What about something else like Option
? In Scala it is common to use Option[A]
where you would use A | null
in typescript. So instead of code like this:
val x: String = getSomething() // maybe null
val y = if (x != null) { f(x) } else { null }
You can write:
val x: Option[String] = getSomething()
val y = x.map(f)
Let's say we want to call f
, g
, and h
as above except any of them might also return null
/None
. First without Option
:
val x: Int = getSomething() // maybe null
val y = if (x != null) {
val l = h(x)
if (l != null) {
val m = g(l)
if (m != null) {
f(m)
} else {
null
}
} else {
null
}
} else {
null
}
More likely you would move the null handling to inside each of the functions you're calling but you still end up with all the same boiler plate, it's just shuffled around and tucked away. Not very DRY. Now with Option
(assuming h
, g
, and f
are the same as above except they return Option[Int]
instead of Int
):
val x: Option[Int] = getSomething()
x.flatMap(h).flatMap(g).flatMap(f)
Much better.
In a much more general sense, a monad represents a sort of container (note that Promise
, Future
, and Option
seen above all "contain" a value) with particular operations (then
/map
/flatMap
) designed to allow you to apply functions to the values "inside" the container.
The particulars of how map
or flatMap
work will vary between different monads. Option
and Future
have roughly analogous behvaior (although, implementation details differ dramatically -- Option.map/flatMap
is a synchronous operation while the same methods on Future
happen asynchronously). List
is similar except instead of 0 or 1 item as with Option
, List
operates on 0 or more items. map
and flatMap
iterate the list and apply operations on each element directly. flatMap
on List
is interesting because it allows expanding or filtering the list:
val l = List(1,2,3)
// repeat each element
val expanded = l.flatMap { item => List(item, item) }
// List(1,1,2,2,3,3)
// removes 2
val filtered = l.flatMap { item => if (item == 2) Nil else List(item) }
// List(1,3)
Many elementary operations can be defined in terms of flatMap
(including map
):
val l: List[A]
def filter(predicate: A => Boolean) = l.flatMap { item => if (predicate(item)) List(item) else Nil }
def map[B](f: A => B): List[B] = l.flatMap { item => List(f(item)) }
def product[B](other: List[B]): List[(A, B)] = l.flatMap { ls => other.flatMap { os => List((ls, os)) } }
Generalizing further you can replace List[A]
with M[A]
:
val l: M[A]
def filter(predicate: A => Boolean) = l.flatMap { item => if (predicate(item)) M.unit(item) else M.empty }
def map[B](f: A => B): M[B] = l.flatMap { item => M.unit(f(item)) }
def product[B](other: M[B]): M[(A, B)] = l.flatMap { ls => other.flatMap { os => M.unit((ls, os)) } }
M
replaces List
and M.unit()
replaces List()
.
By providing flatMap
you get a lot of other operations "for free". This is why Monads are a popular pattern. It generalizes operations on "collection-like" data and allows you to re-use implementations between otherwise unrelated data types.
You'll notice Nil
and M.empty
in filter
. This technically moves us into a discussion about Monoid
s but I won't get into that. It's enough to know that something is a Monoid
if it defines empty
and combine
(not seen here). For example the Monoid for numbers can use 0
for empty and addition (+
) for combine.