Advantages of F-bounded polymorphism over typeclass for return-current-type problem

F-Bounded is a great example of what a type system is capable of express, even simpler ones, like the Java one. But, a typeclass would always be safer and better alternative.

What do we mean with safer? Simply, that we can not break the contract of returning exactly the same type. Which can be done for the two forms of F-Bounded polymorphism (quite easily).

F-bounded polymorphism by type member

This one is pretty easy to break, since we only need to lie about the type member.

trait Pet {
  type P <: Pet
  def name: String 
  def renamed(newName: String): P
}

final case class Dog(name: String) extends Pet {
  override type P = Dog
  override def renamed(newName: String): Dog = Dog(newName)
}

final case class Cat(name: String) extends Pet {
  override type P = Dog // Here we break it.
  override def renamed(newName: String): Dog = Dog(newName)
}

Cat("Luis").renamed(newName = "Mario")
// res: Dog = Dog("Mario")

F-bounded polymorphism by type parameter

This one is a little bit harder to break, since the this: A enforces that the extending class is the same. However, we only need to add an additional layer of inheritance.

trait Pet[P <: Pet[P]] { this: P =>
  def name: String 
  def renamed(newName: String): P
}

class Dog(override val name: String) extends Pet[Dog] {
  override def renamed(newName: String): Dog = new Dog(newName)

  override def toString: String = s"Dog(${name})"
}

class Cat(name: String) extends Dog(name) // Here we break it.

new Cat("Luis").renamed(newName = "Mario")
// res: Dog = Dog(Mario)

Nevertheless, it is clear that the typeclass approach is more complex and has more boilerplate; Also, one can argue that to break F-Bounded, you have to do it intentionally. Thus, if you are OK with the problems of F-Bounded and do not like to deal with the complexity of a typeclass then it is still a valid solution.

Also, we should note that even the typeclass approach can be broken by using things like asInstanceOf or reflection.


BTW, it is worth mentioning that if instead of returning a modified copy, you want to modify the current object and return itself to allow chaining of calls (like a traditional Java builder), you can (should) use this.type.

trait Pet {
  def name: String

  def renamed(newName: String): this.type
}

final class Dog(private var _name: String) extends Pet {
  override def name: String = _name

  override def renamed(newName: String): this.type = {
    this._name = newName
    this
  }

  override def toString: String = s"Dog(${name})"
}

val d1 = Dog("Luis")
// d1: Dog = Dog(Luis)

val d2 = d1.renamed(newName = "Mario")
// d2: Dog = Dog(Mario)

d1 eq d2
// true

d1
// d1: Dog = Dog(Mario)

Leave a Comment