Search code examples
scalascala-3

Understanding definition and desugaring of "Option" in Scala 3 book


I'm starting a Scala role in a few weeks yet I haven't written any Scala before (yes, my future employers know this), although I've written a lot of C# and Haskell. Anyway I was skimming through the Scala 3 book, and found this example:

enum Option[+T]:
  case Some(x: T)
  case None

Which apparently dusugars into:

enum Option[+T]:
  case Some(x: T) extends Option[T]
  case None       extends Option[Nothing]

My two questions are:

  1. How exactly does the mechanism of this desugaring work? In particular why does Some by default extend Option[T] whereas None extends Option[Nothing]?
  2. This seems like a strange way to define Option. I would define it like this:
enum Option[+T]:
  case Some(x: T) extends Option[T]
  case None       extends Option[T]

Indeed with None extending Option[None] wouldn't this fail?

Option[string] x = None;

As Option[T] is covariant in T and None is not a subtype of string?

I'm missing something quite fundamental here I'm sure.


Solution

  • An excellent source for how desugaring of enums works is the original proposal at Issue #1970.

    The relevant section for your question on that page is titled "Desugarings". Let's take a look at your Option definition.

    enum Option[+T]:
      case Some(x: T)
      case None
    

    Some is a parameterized case under an enum with type parameters, so Rule #4 applies

    If E is an enum class with type parameters Ts, then a case in its companion object without an extends clause

    case C <params> <body>
    

    ...

    For the case where C does not have type parameters, assume E's type parameters are

    V1 T1 > L1 <: U1 ,   ... ,    Vn Tn >: Ln <: Un      (n > 0)
    

    where each of the variances Vi is either '+' or '-'. Then the case expands to

    case C <params> extends E[B1, ..., Bn] <body>
    

    So your Some case gets an extends clause for Option[T], as you'd expect. Then Rule #5 gets us our actual case class.

    On the other hand, your None, by the same token, uses no type parameters, so the result is based on variance.

    • If T is invariant, we have to specify an extends clause explicitly
    • If T is covariant, we get Nothing
    • If T is contravariant, we get Any

    Your T is covariant, so we extend Option[Nothing], which remember is a subtype of Option[S] for any S. Hence, your assignment (and remember, in Scala, we use val / var to declare variables, not just the type name)

    val x: Option[String] = None;
    

    None is a value of type None.type, which extends Option[Nothing]. Option[Nothing] is a subtype of Option[String] since Nothing is a subtype of String. So the assignment succeeds.

    This is the same reason Nil (the empty list) extends List[Nothing]. I can construct a Nil which has a concrete type (a subtype of List[Nothing]), and then later I can come along and prepend whatever I want to it. The resulting list is no longer a List[Nothing]; 1 +: Nil is a List[Int] and "a" +: Nil is a List[String], but the point is, that Nil can come from anywhere in my program and we don't have to decide what type we want it to be up-front. We can't (safely) use covariance on a mutable data type, so this all only works because List and Option are immutable. A mutable collection like ArrayBuffer is invariant in its type parameter. (Note: Java's built-in arrays are covariant, and that's unsound and causes all kinds of problems)