The issue is that most common examples of, say, Functor
, also happen to be Monad
, which makes it hard to see the subtleties of intermediate elements.
The purpose of this post, then, is to give examples of things that are things, but not other things - things that are a Functor
, for example, but not an Apply
.
We’ll simply go through each atomic path in the diagram and give an example of something that is the source, but not the target, of each arrow. This will cover every possible path transitively: if, for example, we have an example of something that is a Functor
but not an Apply
, it won’t be an Applicative
, FlatMap
or Monad
either.
Note that this article is not intended to be very formal. I’ll wave my hands quite a bit, and use relatively lose definitions of the various categorical abstractions. The point is not for this to be absolutely precise (it would involve quite a bit more uninteresting code), but to give intuitions.
Functor
This one is actually a little bit tricky: most type constructors are Functor
s.
A good example of something that isn’t, however, is Predicate
:
type Predicate[A] = A => Boolean
Predicate
makes it impossible to write map
:
def map[A, B](pa: Predicate[A])(f: A => B): Predicate[B] =
(b: B) => ???
We get stuck immediately: we have a B
, but both pa
and f
take an A
. There’s nothing we can do with that B
, and certainly not turn it into a Boolean
.
Predicate
is not a Functor
because there’s no way of implementing map
for it.
Technically, that’s because our parameter appears in contravariant position, making Predicate
a contravariant Functor
(as opposed to a covariant one, which is what most people mean when they use the short form Functor
).
Functor
, but not an Apply
The crucial difference between Functor
and Apply
is that the latter can work with 2 (or more) values. Anything that is an Apply
will have a map2
function:
def map2[A, B, C](la: F[A], lb: F[B])(f: (A, B) => C): F[C] =
???
What we need to find is a type for which we can implement map
, but not map2
.
Bearing that in mind, take labelled data: arbitrary data associated with a label (a log level, in our example, just to have something).
enum Label:
case Debug, Info, Warn, Error
case class Labelled[A](label: Label, value: A)
Importantly, there is no reasonable way of combining labels - what would it mean, for example, to combine Debug
and Error
?
Labelled
has an obvious map
implementation, but map2
is problematic:
def map2[A, B, C](la: Labelled[A], lb: Labelled[B])(f: (A, B) => C): Labelled[C] =
Labelled(
value = f(la.value, lb.value),
label = ???
)
By specification, we can’t go any further. It will be impossible to combine the labels of la
and lb
, because labels cannot be combined.
We could, had we no shame, write something like this:
def map2[A, B, C](la: Labelled[A], lb: Labelled[B])(f: (A, B) => C): Labelled[C] =
Labelled(
value = f(la.value, lb.value),
label = Label.Error // Oh, the shame.
)
But on top of the obvious difficulties we’d have looking at ourselves in the mirror, this is a little bit of a cheat: we’ve decided that combining two labels always resulted in Error
. Our implementation is technically correct, and even has the properties you’d expect (such as associativity), but… when the specifications say you can’t combine labels and we had to, well, combine labels, in order to write map2
, it’s a little bit hard to argue that our implementation is valid.
This is a pattern we’ll encounter again: sometimes, we’ll be able to write something that matches the shape of the data, but not its semantics.
You can generalise the concept of Labelled
to the product of a type parameter (A
) with a type that does not admit a semigroup (Label
).
It can be entertaining, for an admittedly novel definition of the term, to think about how writing our bad map2
implementation is actually giving Label
a Semigroup
instance, and thus breaking the generic Functor
but not Apply
pattern.
Apply
, but not an Applicative
The crucial difference between an Apply
and an Applicative
is that the latter knows how to lift values: anything that is an Applicative
must have a pure
function.
def pure[A](a: A): F[A] =
???
What we need to find is a type for which we can implement map2
but not pure
.
We can use the same idea as for Apply
and take a product type that prevents us from implementing pure
. It must, however, be a type for which it makes sense to combine values.
Take weighted data:
case class Weighted[A](weight: PosInt, value: A)
Where PosInt
is any integer greater than 0 (such as the one defined in the refined library).
There is no issue writing map2
, we can just:
f
.On the other hand, pure
is more problematic:
def pure[A](a: A): Weighted[A] =
Weighted(
value = a,
weight = ???
)
There is no obvious value we can use for the weight, as the only one that would make sense, 0, is not a valid PosInt
.
And thus, Weighted
is an Apply
, but not an Applicative
.
Weighted
is an instance of a more generic pattern: the product of an A
with something that admits a Semigroup
(to combine values) but not a Monoid
(to prevent pure
implementations) will always be an Apply
, but not an Applicative
.
Applicative
, but not a Monad
I know of 2 different families of counter-examples for this.
flatMap
One set of counter examples would be all the types for which you cannot implement flatMap
without cheating.
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] =
???
The trick here is to make it impossible to call f
, which is easily achieved by not having an A
to apply it on. For example:
case class Flag[A](flag: Boolean)
Flag
doesn’t actually keep track of the A
it refers to, but only of whether it has been flagged. We’ll consider that the behaviour of the flag is the one you’d intuitively expect:
false
(which gives us pure
).OR
(which gives us map2
).flatMap
is problematic, however:
def flatMap[A, B](fa: Flag[A])(f: A => Flag[B]): Flag[B] =
Flag(
flag = ???
)
We have nothing to call apply f
to. The only way we could conceivably implement flatMap
is by ignoring f
altogether and taking fa
’s flag:
def flatMap[A, B](fa: Flag[A])(f: A => Flag[B]): Flag[B] =
Flag(
flag = fa.flag
)
Intuitively though, this is wrong - f
might be doing important work and return a useful flag, and we’re ignoring that. Our implementation typechecks, but it cannot be legal.
This intuition is captured by the left associativity monad law, which tells us that sane implementations should respect:
// Lifting `a` and flatMapping `f` into it must be equivalent to
// just applying `f` to `a`.
flatMap(pure(a))(f) == f(a)
And we can easily break it using our bad implementation:
val f = (i: Int) => Flag(i % 2 != 0)
val a = 1
flatMap(pure(a))(f)
// Flag(false)
f(a)
// Flag(true)
And again, intuitively, this all makes sense: flatMap
captures chained computations - computations that depend on the result of previous ones. With Flag
, we don’t store the result of previous computations, which makes it quite a bit harder to depend on them.
Note that Flag
is a specialisation of a more generic pattern: Const
, where the non-phantom type parameter admits a Monoid
, is an Applicative
but not a Monad
.
Our second example is Validated
, which is another example of a type for which we can write an instance that works for the shape of the data, but not its semantics.
The purpose of Validated
is to encode computations that can fail. You might argue that we already have Either
for that, but there’s a subtle difference: Either
fails on the first error, where Validated
will accumulate all of them.
But, yes, Validated
is very similar to Either
, so much so that its shape is exactly the same:
type Validated[E, A] = Either[E, A]
By constraining the error type to a Semigroup
, we can easily implement an instance of Applicative
for Validated
. Here’s the map2
implementation, for example:
def map2[A, B, C, E: Semigroup](lhs: Validated[E, A], rhs: Validated[E, B])(f: (A, B) => C): Validated[E, C] =
(lhs, rhs) match
case (Left(e1), Left(e2)) => Left(combine(e1, e2))
case (Left(e1), _) => Left(e1)
case (_, Left(e2)) => Left(e2)
case (Right(a), Right(b)) => Right(f(a, b))
We’ll combine errors if there are more than one, ignore successes if we have an error, and combine successes using the specified function otherwise. A little boilerplatey, certainly, but not terribly hard to write.
We encounter a problem when trying to write flatMap
, however:
def flatMap[A, B, E: Semigroup](fa: Validated[A])(f: A => Validated[B]): Validated[B] =
fa match
case Right(a) => f(a)
case Left(e) => ???
The success case is straightforward, but the error one is a little problematic: f
takes an A
, but we don’t have one. This means we can’t run the second part of the computation, and will not know whether it fails. We cannot accumulate errors.
You might argue that we could definitely write something that works - just return the same error. This would type check, but, and this is important, not match our specifications. We want to accumulate errors, but that flatMap
implementation doesn’t. We’ve provided an answer, certainly, but to a different problem (it would, in fact, be the right flatMap
implementation for Either
).
The observation we made for Flag
applies here too: intuitively, it makes sense that Validated
cannot have a flatMap
implementation. In order to accumulate errors, you need to be able to run your computations independently, and flatMap
is exactly for running computations that depend on each other.
Apply
, but not a FlatMap
The first thing to realise is that we already have examples of this pattern. We’ve already come up with types that are Applicative
but for which we couldn’t write flatMap
. Since an Applicative
is also an Apply
, then an Applicative
for which we cannot implement flatMap
is… exactly what we’re looking for.
It feels like a bit of a cheat, however. When we say that a type is an Apply
, there’s the implication that it’s not an Applicative
. So how could we come up with something that is an Apply
, but not an Applicative
or FlatMap
?
Well, we could merge the patterns of Apply
but not Applicative
and Applicative
but not FlatMap
:
Semigroup
, but not Monoid
).A
on which to apply the function passed to flatMap
.This should look very familiar, as it mixes Flag
and Weighted
:
case class Weight[A](weight: PosInt)
Weight
is an Apply
, with the obvious implementations of map
and map2
.
Weight
is not an Applicative
for the same reason that Weighted
isn’t: we can’t provide a default PosInt
value.
Weight
is not a FlatMap
for the same reason that Flag
isn’t: we can’t implement a flatMap
that actually uses f
.
Like Flag
, Weight
is a specialisation of Const
, but with slightly different constraints: any Const
where the non-phantom type parameter admits a Semigroup
but not a Monoid
is an Apply
but not a FlatMap
.
FlatMap
, but not a Monad
The only difference between a FlatMap
and a Monad
is pure
: a Monad
must be able to lift values.
We’re looking, then, for something that supports flatten
but not pure
, where flatten
is:
def flatten[A](ffa: F[F[A]]): F[A] =
???
Consider key/value pairs, such as Environment
:
type Environment[A] = Map[NonEmptyString, A]
Each key represents the path to a value, represented as a NonEmptyString
- where NonEmptyString
is the obvious type, such as the one defined in refined.
Flattening nested Environment
s is achieved by merging paths:
def flatten[A](eea: Environment[Environment[A]]): Environment[A] =
val builder = Map.newBuilder[NonEmptyString, A]
eea.foreach { case (parent, ea) =>
ea.foreach { case (child, a) =>
builder += s"$parent.$child" -> a
}
}
builder.result()
Here’s an illustration of how this behaves, to clarify things:
flatten(Map(
"a" -> Map(
"b" -> 1,
"c" -> 2
),
"d" -> Map(
"e" -> 3,
"f" -> 4
)
))
// Map(a.b -> 1, a.c -> 2, d.e -> 3, d.f -> 4)
flatten
wasn’t much of a problem, but what about pure
?
def pure[A](a: A): Environment[A] =
Map(
??? -> a
)
We don’t have a key to associate with the specified value, and have no means of summoning one out of thin air. pure
cannot be implemented, and thus, Environment
is a FlatMap
that isn’t a Monad
.
I specialised things to a Map[NonEmptyString, A]
here, to make things clear, but this actually generalises to Map[A, B]
, as discussed, for example, here.