Skip to content

07 · Higher-Kinded Types Intro

You've already written F[_] several times — in the Functor[F[_]] type class from module 04 and the generic printAll[A: Show] from module 06. This module slows down on exactly what F[_] means, why it's called "higher-kinded," and where it stops being useful.

Kinds: types of types

An ordinary type like Int or String has kind * (pronounced "type") — it's a complete, concrete type you can have a value of. List by itself is not a complete type — you can't have a value of type List — it needs a type argument first: List[Int] is a complete type, but List alone is a type constructor, something that takes a type and produces a type. Its kind is written * -> *.

val x: Int = 5            // Int has kind *
val xs: List[Int] = List(1, 2, 3)   // List[Int] has kind *, List alone has kind * -> *

A higher-kinded type is a type parameter that is itself a type constructor rather than a concrete type — written F[_] in Scala, meaning "some type constructor that takes one type parameter." This is exactly what lets Functor[F[_]] be generic over List, Option, Future, or any other single-parameter container, rather than being written separately for each one.

Writing code parameterized over the container itself

trait Container[F[_]]:
  def pure[A](a: A): F[A]
  def map[A, B](fa: F[A])(f: A => B): F[B]

given Container[List] with
  def pure[A](a: A): List[A] = List(a)
  def map[A, B](fa: List[A])(f: A => B): List[B] = fa.map(f)

given Container[Option] with
  def pure[A](a: A): Option[A] = Some(a)
  def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa.map(f)

def wrapAndDouble[F[_]](x: Int)(using c: Container[F]): F[Int] =
  c.map(c.pure(x))(_ * 2)

println(wrapAndDouble[List](5))     // List(10)
println(wrapAndDouble[Option](5))   // Some(10)

Notice F[_] appears in Container's definition and again in wrapAndDouble's type parameter list — F is never given a concrete type argument in either signature. It's abstracted over the shape "a container of one thing," and the concrete shape (List, Option, your own type) is only decided at the call site via [List]/[Option].

Your own type constructor works the same way

case class Box[A](value: A)

given Container[Box] with
  def pure[A](a: A): Box[A] = Box(a)
  def map[A, B](fa: Box[A])(f: A => B): Box[B] = Box(f(fa.value))

println(wrapAndDouble[Box](5))   // Box(10)

wrapAndDouble didn't change at all — Box slots into the exact same higher-kinded parameter as List and Option because it also has kind * -> * (one type parameter) and a lawful Container instance.

Sized[F[_]]: extension methods on higher-kinded parameters

Type classes over F[_] can also add extension methods usable inside generic functions, exactly like the ordinary type classes from module 06:

trait Sized[F[_]]:
  extension [A](fa: F[A]) def size: Int

given Sized[List] with
  extension [A](fa: List[A]) def size: Int = fa.length

def describe[F[_]: Sized, A](fa: F[A]): String = s"size=${fa.size}"

println(describe(List(1, 2, 3)))   // size=3

[F[_]: Sized, A] combines a context bound on a higher-kinded parameter F[_] with an ordinary type parameter A — the same context-bound sugar from module 06, just applied one level up.

The trap: kind mismatches read as cryptic errors

Passing Int (kind *) where an F[_] (kind * -> *) is expected — or vice versa — produces an error like Int takes no type parameters, expected: one. This is the compiler enforcing kinds the same way it enforces types; the fix is almost always that you've mixed up a concrete type with a type constructor, e.g. writing Container[Int] when you meant Container[List] applied to Int elsewhere.

When higher-kinded abstraction stops paying for itself

Not every function needs to be generic over F[_]. If you only ever call wrapAndDouble with List, writing it concretely (def wrapAndDouble(x: Int): List[Int] = List(x * 2)) is simpler to read and just as correct. Reach for F[_] abstraction when you actually have multiple concrete containers that need the same logic (as here, List/Option/Box) — this is the same "don't abstract until you have two real cases" judgment call you already make with ordinary generics.

How It Actually Works

"Kinds" describe types the same way types describe values — a concrete type like Int or String has kind * ("a fully-applied, instantiable type"), while List on its own (without a type argument) has kind * -> *: it's a function from a type to a type, taking one type parameter (List[Int] is *, but bare List is not directly instantiable). This is purely a compile-time classification the type checker maintains — it never appears in bytecode — but it's the exact reason Functor[F[_]] must be written with that underscore: it's declaring "F itself must have kind * -> *," and passing something of the wrong kind (like a fully concrete Int, kind *) is a kind mismatch, rejected before the program ever reaches type-checking of values — which is also why kind-mismatch errors read so cryptically: the compiler is reporting a mismatch one level of abstraction higher than the "wrong type" errors you're used to.

At runtime, higher-kinded polymorphism vanishes almost entirely due to JVM type erasure: after compilation, List[Int] and List[String] are both just List (a raw class) — the JVM has no concept of generic type parameters at the bytecode level, only at the source/compile level via type-checking and inserted casts. A method generic over F[_] compiles to bytecode that manipulates F's erased runtime representation (whatever concrete class you instantiated it with) uniformly, relying entirely on compile-time type checking to have already guaranteed the operations are valid — there's no runtime higher-kinded dispatch mechanism because the JVM doesn't preserve the information needed for one.

"When higher-kinded abstraction stops paying for itself" is a real, measurable trade in exactly this light: every layer of F[_] abstraction is compile-time-only bookkeeping that adds indirection for a human reader (and sometimes an extra allocation, e.g. wrapping a value just to satisfy Functor[F[_]]'s shape) without changing what happens at the bytecode level once erasure and implicit resolution are done — the cost is legibility and compile time, not runtime performance, which is exactly the trade the module's rule of thumb is weighing.

Cheat sheet

Concept Meaning
Kind * An ordinary, complete type (Int, String, List[Int])
Kind * -> * A type constructor needing one type argument (List, Option, Box)
F[_] Scala's syntax for a type parameter of kind * -> *
[F[_]: TypeClass] Context bound on a higher-kinded parameter
When to use it You have ≥2 concrete F[_]s needing the same generic logic

Exercise

Write a type class Emptyable[F[_]] with def isEmpty[A](fa: F[A]): Boolean and given instances for List and Option. Write def firstNonEmpty[F[_]: Emptyable, A](xs: List[F[A]]): Option[F[A]] that returns the first element of xs for which isEmpty is false (or None if all are empty), using only the type class — no pattern matching on List/Option directly inside the generic function. Test it against a List[List[Int]] containing some empty lists and a List[Option[Int]] containing some Nones.