Skip to Content
Course content

132: F-Bounded Polymorphism

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

You've probably run into a situation where you define a base trait and then want a method in that trait to return the specific subtype that's calling it. If you've tried this with standard generics, you know it's frustrating—you usually end up with the base type and a bunch of annoying casts. That's where F-Bounded Polymorphism comes in. It looks like a brain-teaser at first, but it's a powerful pattern for maintaining type fidelity.

Why can't I just use a regular generic type for my return values?

Let's say we're building a system for a game where every entity can be compared to another entity of the same type. If we do it the "obvious" way, it looks like this:

trait Entity {
  def compareTo(other: Entity): Int
}

case class Soldier(health: Int) extends Entity {
  override def compareTo(other: Entity): Int = {
    // I have to cast 'other' to Soldier to actually see the health!
    val s = other.asInstanceOf[Soldier]
    this.health.compareTo(s.health)
  }
}

This is brittle. I'm forcing a cast, and if I accidentally pass a Wizard into Soldier.compareTo, the app crashes at runtime. We want the compiler to prevent us from comparing a Soldier to a Wizard in the first place. We want the compareTo method to know that other is the exact same subtype as the class implementing the trait.

How does F-Bounded Polymorphism actually solve this?

The "F" stands for "fixed point," but in practice, it just means we're using a type parameter that is bounded by the trait itself. We change the trait definition to look like this: trait Entity[T extends Entity[T]].

Here is how that looks in a real implementation:

trait Entity[T extends Entity[T]] {
  def compareTo(other: T): Int
}

case class Soldier(health: Int) extends Entity[Soldier] {
  override def compareTo(other: Soldier): Int = {
    // No casting needed! 'other' is guaranteed to be a Soldier.
    this.health.compareTo(other.health)
  }
}

case class Wizard(mana: Int) extends Entity[Wizard] {
  override def compareTo(other: Wizard): Int = {
    this.mana.compareTo(other.mana)
  }
}

Notice what happened in case class Soldier(health: Int) extends Entity[Soldier]. By passing Soldier into the type parameter, we've told the trait: "For this specific implementation, T is Soldier." Now, the compareTo method signature automatically becomes compareTo(other: Soldier).

What happens if I accidentally put the wrong type in the brackets?

This is where it gets weird. Because the bound is T extends Entity[T], the compiler is usually pretty good at catching mistakes, but it's not foolproof. Technically, you could try to do something like class Chaos extends Entity[Soldier].

While the compiler might let some of these "cross-pollinations" happen depending on the complexity of your hierarchy, it's generally considered a developer error. I've seen people try to get too clever with this and end up with "type-level spaghetti." My advice? Use this pattern specifically when you need to ensure that a method's input or output is the exact same subtype as the receiver. If you don't need that strictness, stick to simple generics.

Can I use this for fluent APIs or cloning?

Absolutely. This is actually one of the most common use cases. Imagine a base Component class with a withName method that returns a copy of the component with a new name. Without F-Bounding, withName would return a Component, and you'd lose all the specific methods of your PhysicsComponent or AIComponent after one call.

By using trait Component[T extends Component[T]] and having withName return T, you preserve the chain. You can call myPhysicsComp.withName("NewName").setGravity(9.8) without ever having to cast back to PhysicsComponent. It makes the API feel seamless to the person using your library.




📋 Practical Task

Implementing a Type-Safe Node Hierarchy

You are building a graph library where different types of nodes (e.g., CityNode, UserNode) can only be connected to nodes of their own type. If a CityNode has a neighbor, that neighbor must also be a CityNode.

Your Task:

  • Create a trait Node[T extends Node[T]].
  • Add a method to the trait called connectTo(other: T) that prints "Connected to [other]".
  • Implement a case class CityNode(name: String) that extends Node[CityNode].
  • Implement a case class UserNode(username: String) that extends Node[UserNode].
  • In your main code, verify that you can connect a CityNode to another CityNode, but that the compiler throws an error if you try to connect a CityNode to a UserNode.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.