Search code examples
scalapattern-matchingtype-parameter

Type Bounds and Pattern Matching in Scala


Let's say I have the following:

trait Person {
  val name: String
}
case class Student(val name: String) extends Person
case class Teacher(val name: String, students: List[Student]) extends Person

I'd like a function function that could take any Person implementation, match on the specific type, and then return the most specific type possible. (I know this might not be the smartest thing, but bear with me.) Let's say something like:

def teacherGreeting(teacher: Teacher): (Teacher, String) = {
  val names = teacher.students.map(_.name).mkString(", ")
  (teacher, s"Hello ${teacher.name}, your students are $names")
}

def greet[P <: Person](person: P): (P, String) = person match {
  case Student(name) => (person, s"Hello $name")
  case Teacher(name, students) => teacherGreeting(person)
}

But then I get:

<console>:19: error: type mismatch;
 found   : P
 required: Teacher
             case Teacher(name, students) => teacherGreeting(person)
                                                         ^

If I have the logic of teacherGreeting inside of greet, I don't have any problems. So, why doesn't the compiler know that P in this branch of the code must be a Teacher?

If I use the matched value:

def greet[P <: Person](person: P): (P, String) = person match {
  case Student(name) => (person, s"Hello $name")
  case teacher @ Teacher(name, students) => teacherGreeting(teacher)
}

The error just happens later, with the result of teacherGreeting, instead of the input:

error: type mismatch;
 found   : (Teacher, String)
 required: (P, String)
             case teacher @ Teacher(name, students) => teacherGreeting(teacher)
                                                                  ^

Is there no way to avoid casting?


Solution

  • When you compile greet(p), the compiler has to infer the type P in

    def greet[P <: Person](person: P): (P, String)
    

    If you then defined a subclass of Person and call greet on that an instance:

    class Parent(val name: String) extends Person
    val parent = new Parent("Joe")
    val (p, greeting) = greet(parent)
    

    The inferred type P is determined at compile time, it does not depend on the runtime type of parent. So the compiler would have to infer P as Parent: greet[Parent](parent).

    But then how would these expressions be typed? In order to type check, since P is Parent, they have to be of type (Parent, String):

    case teacher @ Teacher(name, students) => teacherGreeting(teacher)
    case Teacher(name, students) => teacherGreeting(person)
    

    In the first case, the return type of teacherGreeting(teacher) is (Teacher, String). And Teacher is not a Parent. The return type does not match.

    In the second case, you are calling teacherGreeting(person: Parent) so it is wrong type for the argument.

    When you say you don't have a problem if you inline the body of teacherGreeting inside the second case, that is probably because you return (person, "str"). And in that case person would be of type P for sure. But when you pass it through teacherGreeting, the compiler does not know that you are returning the passed argument of type P. For all we know you could be returning Teacher("another", List()).

    Edit: so thinking on how to preserve the type P here is a (cumbersome) way to go about it. You want to retain the type through the teacherGreeting call. This can be done like this. Use a type parameter Q that will be inferred as the P from greet:

    def teacherGreeting[Q <: Teacher](teacher: Q): (Q, String) = {
      val names = teacher.students.map(_.name).mkString(", ")
      (teacher, s"Hello ${teacher.name}, your students are $names")
    }  
    

    Tell the compiler that teacher is a P and a Teacher:

    def greet[P <: Person](person: P): (P, String) = person match {
      case Student(name) => (person, s"Hello $name")
      case teacher: (P with Teacher) => teacherGreeting(teacher)
    }