Skip to Content
Course content

130: Higher-Kinded Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Look, I'll be honest with you: this is the point in the course where a lot of developers start to feel like they're hitting a wall. Higher-Kinded Types (HKTs) sound like something out of a PhD thesis, but once you strip away the academic jargon, they're actually a very practical tool for reducing repetition in your architecture.

Think about a shipping company. They have a standard set of rules for how to handle any "Package." It doesn't matter if the package is a cardboard box, a wooden crate, or a plastic bin—as long as it is a "Package," the company knows how to put a shipping label on it and how to weigh it. The type of container (box, crate, bin) is the wrapper, and the item inside (a toaster, a vase, a book) is the content. The shipping company doesn't care what the item is, and they don't even care which specific container is being used, as long as that container follows the "Package" standard.

In Scala, a regular generic like List[Int] is like saying "here is a box containing an integer." A Higher-Kinded Type is like talking about the concept of the box itself, regardless of what's inside it. We aren't talking about List[Int] or Option[String]; we're talking about F[A], where F is a placeholder for the container and A is a placeholder for the content.

When a Generic Isn't Enough

You're already used to generics. You can write a function that works for any T. But what if you want to write a function that works for any container? For example, imagine you want a map function. You know List has a map, Option has a map, and Future has a map. You might be tempted to write a trait like this:

trait Mapper[A, B] {
  def map(fa: A): B
}

But that doesn't work. A and B here are just types. If you pass in a List[Int] as A, then B becomes List[String]. The trait has no way of knowing that the "container" (the List part) should remain the same while only the "content" (the Int to String part) changes. This is where we need the F[_] syntax.

The Power of the F[_] Notation

The [_] is the magic bit. It tells Scala: "This isn't just a type; it's a type that takes another type." It's a type constructor. When you see F[_], think of it as a hole that needs to be filled with something like List or Option.

Let's build a simple Functor. In functional programming, a Functor is just a fancy name for "something that can be mapped over."

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

Notice how F is defined in the trait header as F[_]. This allows the map method to use F as a container for any A and B. I've used this pattern in production systems to create data-layer abstractions where the business logic doesn't know if the result is wrapped in a Future, an Either, or a List—it just knows that it can map over the result.

Bridging the Gap with Implementations

Defining the trait is the easy part. The real work happens when you provide the implementation for specific containers. Here is how we'd tell Scala how to treat Option and List as Functors:

object FunctorInstances {
  implicit val optionFunctor: Functor[Option] = new Functor[Option] {
    def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa.map(f)
  }

  implicit val listFunctor: Functor[List] = new Functor[List] {
    def map[A, B](fa: List[A])(f: A => B): List[B] = fa.map(f)
  }
}

Now, we can write a function that is truly polymorphic. It doesn't care if it's dealing with a list or an option; it just needs F to be a Functor.

def incrementAll[F[_], A](container: F[A])(implicit functor: Functor[F])(f: A => A): F[A] = {
  functor.map(container)(f)
}

// Usage:
import FunctorInstances._
val listRes = incrementAll(List(1, 2, 3))(x => x + 1) // Works!
val optRes = incrementAll(Some(1))(x => x + 1)       // Also works!

By using HKTs, we've successfully decoupled the operation (mapping a function) from the structure (the container). It's a bit of an investment in syntax, but it prevents you from writing the same logic five times just because your data is wrapped in different types of containers.




📋 Practical Task

Implementing a Generic Container Filter

In this exercise, you will create a typeclass called Filterable. Much like the Functor example in the lesson, Filterable should allow us to filter the contents of a container, provided that container supports filtering.

Requirements:

  • Define a trait Filterable[F[_]] with a method filter[A](fa: F[A])(p: A => Boolean): F[A].
  • Create an implicit instance of Filterable for List.
  • Create an implicit instance of Filterable for Option (Hint: if the predicate is false, the Option should become None).
  • Write a function called keepPositiveNumbers[F[_]] that takes a container F[Int] and uses the Filterable instance to remove all numbers less than or equal to zero.

Test your code with these cases:

val numbersList = List(-1, 2, -3, 4)
val numbersOpt = Some(-5)

// Should return List(2, 4)
val resultList = keepPositiveNumbers(numbersList) 

// Should return None
val resultOpt = keepPositiveNumbers(numbersOpt) 
Rating
0 0

There are no comments for now.

to be the first to leave a comment.