Higher-Kinded Types
Prerequisites: Type classes, Variance
List, Option, and Future aren't types on their own — they're type
constructors that need a type argument (List[Int]). A
higher-kinded type parameter abstracts over them: F[_].
Abstracting over containers
trait Functor[F[_]]:
extension [A](fa: F[A]) def fmap[B](f: A => B): F[B]
given Functor[List] with
extension [A](fa: List[A]) def fmap[B](f: A => B): List[B] = fa.map(f)
given Functor[Option] with
extension [A](fa: Option[A]) def fmap[B](f: A => B): Option[B] = fa.map(f)
def doubleAll[F[_]: Functor](numbers: F[Int]): F[Int] = numbers.fmap(_ * 2)
@main def functors(): Unit =
println(doubleAll(List(1, 2, 3)))
println(doubleAll(Option(21)))
println(doubleAll(Option.empty[Int]))
List(2, 4, 6)
Some(42)
None
doubleAll is written once and works for any F with a Functor
instance. F[_]: Functor is a context bound on a type constructor.
Monads
Add pure (wrap a value) and flatMap (chain computations) and you get a
Monad — the abstraction behind for-comprehensions:
trait Monad[F[_]]:
def pure[A](a: A): F[A]
extension [A](fa: F[A])
def flatMap[B](f: A => F[B]): F[B]
def map[B](f: A => B): F[B] = fa.flatMap(a => pure(f(a)))
given Monad[Option] with
def pure[A](a: A): Option[A] = Some(a)
extension [A](fa: Option[A])
def flatMap[B](f: A => Option[B]): Option[B] = fa match
case Some(a) => f(a)
case None => None
def addAll[F[_]: Monad](a: F[Int], b: F[Int], c: F[Int]): F[Int] =
for
x <- a
y <- b
z <- c
yield x + y + z
@main def monads(): Unit =
println(addAll(Option(1), Option(2), Option(3)))
println(addAll(Option(1), None, Option(3)))
Some(6)
None
In practice
You'll rarely define these yourself — Cats provides Functor,
Monad, Traverse, and many more, with instances for standard types.
But recognizing F[_] lets you read "tagless final" code like
def loadUser[F[_]: Monad](id: Int): F[User], which works with any effect
type — IO in production and a simple test type in unit tests.