Search code examples
scalascala-3

Why does a match on covariant enum does not behave the same as with a sealed trait?


Why does this produce an error?

import scala.io.StdIn

enum Flow[+A] {
  case Say (text: String) extends Flow[Unit]
  case Ask extends Flow[String]
}

def eval [T](flow: Flow[T]): T = flow match {
  case Flow.Say(text) => println(text)
  case Flow.Ask => StdIn.readLine // error here
}
18 |  case Flow.Ask => StdIn.readLine
   |                   ^^^^^^^^^^^^^^
   |                   Found:    String
   |                   Required: T

(no error if the enum is left invariant on T)

But when the enum is replaced with a (seemingly equivalent?) ADT implemented with a sealed trait, no such error is reported:

sealed trait Flow[+A]
object Flow {
  case class Say(text: String) extends Flow[Unit]
  case object Ask extends Flow[String]
}
// match statement in eval works fine

Solution

  • We can look at the initial proposal which added enums to Scala 3 for some insight.

    By the wording of this proposal, enum cases fall into three categories.

    • Class cases are those cases that are parameterized, either with a type parameter section [...] or with one or more (possibly empty) parameter sections (...).
    • Simple cases are cases of a non-generic enum class that have neither parameters nor an extends clause or body. That is, they consist of a name only.
    • Value cases are all cases that do not have a parameter section but that do have a (possibly generated) extends clause and/or a body.

    Let's take a look at your enum declaration.

    enum Flow[+A] {
      case Say (text: String) extends Flow[Unit]
      case Ask extends Flow[String]
    }
    

    Now, simple cases are right out, because your enum is generic. Your Say is pretty clearly a class case, since it's parameterized. Ask, on the other hand, is non-generic and non-enumerated, so it's a value case. If we scroll down a bit, we see what value cases do

    (6) A value case

    case C extends <parents> <body>
    

    expands to a value definition

    val C = new <parents> { <body>; def enumTag = n; $values.register(this) }
    

    where n is the ordinal number of the case in the companion object, starting from 0. The statement $values.register(this) registers the value as one of the enumValues of the enumeration (see below). $values is a compiler-defined private value in the companion object.

    The bottom line here is that Ask is not defined as

    object Ask extends Flow[String]
    

    but more like

    val Ask = new Flow[String]() { ... }
    

    So your pattern match

    case Flow.Ask => StdIn.readLine
    

    is actually an equality check against the value Flow.Ask, rather than a type check against a singleton object. The former, unfortunately, proves nothing to the compiler about the generic type of your value, so it remains T, as opposed to specializing to String.

    The rationale for this is provided on the same page

    Objectives

    ...

    1. Enumerations should be efficient, even if they define many values. In particular, we should avoid defining a new class for every value.

    ...

    So it seems the Scala developers wanted to avoid the bloat that would result from an enum declaring tons of unparameterized values (i.e. a type that looks like an ordinary Java-style enum).

    The simple solution, then, is to provide a parameter list

    case Ask() extends Flow[String]
    

    It's ever so slightly more cumbersome to work with, but it does define a new type, and then your pattern match

    case Flow.Ask() => StdIn.readLine
    

    will succeed