Proposal for Multi-level Enums

Should this be opened as an issue in https://github.com/lampepfl/dotty/issues? (Or https://github.com/lampepfl/dotty-feature-requests - are multi-level enums a feature request or a bug report? :boxing_glove:)

2 Likes

Multi-level enums could be useful but could be added later, if necessary.

In the meantime, the feature can be emulated rather directly, without the need to abandon enums:

e.g.:

enum MyEnum {
  case A
  case B(i: Int)
  case C(x: X)
}

final case class X(c: C)

enum C {
  case D(s: String)
  case E
}

Indeed, this would be the idiomatic encoding of this ADT in Haskell and other functional programming languages, which support products of sums, but not “sums of sums”, since those flatten into sums (it is only an artifact of Scala’s encoding of sums using subtyping that such a multi-level sum is even possible).

Why use an intermediate X case class? You can have:

package foo

enum MyEnum {
  case A
  case B(i: Int)
  case C(c: foo.C)
}

enum C {
  case D(s: String)
  case E
}

Though this version still allocate a useless wrapper at runtime (the MyEnum.C case). I don’t see a reason to add a wrapper when you could just have a nested enum. But I agree that nested enums can always be added later, and so are not on the critical path.

You could just as well say that it’s an artifact of Haskell’s lack of subtyping that these are not possible in Haskell.

7 Likes

Oh, and while we’re talking about Haskell, it’s funny to read GHC’s source and find things like:

-- INVARIANT: The cast is never refl.
-- INVARIANT: The Type is not a CastTy (use TransCo instead)

…which could be expressed by having a more refined, multi-level ADT. I guess they don’t use wrappers there because of the added performance overhead and boilerplate these would incur.

1 Like

Agreed. I think they’d be helpful, and it’s worth a feature request, but let’s not make the best be the enemy of the good – if multi-level isn’t in scope for 3.0, we should think about how to do it in a later release.

3 Likes

Are enums final, not sealed ? What’s stopping me from writing

enum MyEnum {
  case A
  case B(i: Int)
}

enum C extends MyEnum {
  case D(s: String)
  case E
}

in the same file?

Edit: there’s a specific prohibiting error message…

class C in package  extends enum MyEnum, but extending enums is prohibited.

But it’s possible to employ a pattern hiding the enum type for a horrible workaround:

sealed trait MyEnum
final lazy val MyEnum = MyEnum0

private enum MyEnum0 extends MyEnum {
  case A
  case B(i: Int)
}

enum C extends MyEnum {
  case D(s: String)
  case E
}

I actually believe they are essential to start with, assuming that the motivation is to help reduce boilerplate, and I don’t find much boilerplate in simple (1 level) ADT hierarchies.

I think it’s crucial to explore how this new syntax is going to help remove boilerplate or create a simpler and more readable syntax for ADTs. For that, I believe we need actual examples, comparing between the old and the potentially new syntax.

I have one such example of my own, in which my ADTs also have defs in them (I’d imagine this is a fairly common use-case?). My ADTs represent a container of a single value, somewhat emulating a REST response. It’s similar to Option, but rather than have one “none” case, there are many variations of no-value cases.

You could find the following code examples – along with a variation of the Scala 2 implementation that includes scaladocs – in this gist.

  1. Implementation in Scala 2:
sealed trait Response[+A] {
  def returnValue: Option[A]
  def map[B](f: A => B): Response[B]
  def flatMap[B](f: A => Response[B]): Response[B]
}

case class Success[+A](value: A) extends Response[A] {
  override val returnValue: Option[A] = value match {
    case _: Unit => None
    case _       => Option(value)
  }
  override def map[B](f: (A) => B): Response[B] =
    try {
      Success(f(value))
    } catch {
      case NonFatal(e) => GenericFailure(s"Failed to map service response with value: $value", Some(e))
    }
  override def flatMap[B](f: A => Response[B]): Response[B] = {
    f(value)
  }
}

object Success {
  def empty: Success[Unit] = Success(())
}

trait NoValueResponse {
  self: Response[Nothing] =>
  override val returnValue: Option[Nothing] = None
  override def map[B](f: (Nothing) => B): Response[B] = this
  override def flatMap[B](f: (Nothing) => Response[B]): Response[B] = this
}

object NoChange extends Response[Nothing] with NoValueResponse

object Accepted extends Response[Nothing] with NoValueResponse

sealed trait Failure extends Response[Nothing] with NoValueResponse {
  def message: String
}

case class NoSuchElement(message: String) extends Failure

case class IllegalArgument(message: String, cause: Option[Throwable] = None) extends Failure

case class GenericFailure(message: String, cause: Option[Throwable] = None) extends Failure
  1. Implementation in hypothetical multi-level ADT syntax:
sealed trait Response[+A] {
  def returnValue: Option[A]
  def map[B](f: A => B): Response[B]
  def flatMap[B](f: A => Response[B]): Response[B]
  
  case Success(value: A) {
    override val returnValue: Option[A] = value match {
      case _: Unit => None
      case _       => Option(value)
    }
    override def map[B](f: (A) => B): Response[B] =
      try {
        Success(f(value))
      } catch {
        case NonFatal(e) => GenericFailure(s"Failed to map service response with value: $value", Some(e))
      }
    override def flatMap[B](f: A => Response[B]): Response[B] = {
      f(value)
    }
  }
  
  case sealed trait NoValueResponse {
    override val returnValue: Option[Nothing] = None
    override def map[B](f: (Nothing) => B): Response[B] = this
    override def flatMap[B](f: (Nothing) => Response[B]): Response[B] = this
    
    case NoChange
    case Accepted
    case sealed trait Failure {
      def message: String
      
      case NoSuchElement(message: String)
      case IllegalArgument(message: String, cause: Option[Throwable] = None)
      case GenericFailure(message: String, cause: Option[Throwable] = None)
    }
  }
}

object Success {
  def empty: Success[Unit] = Success(())
}
  1. Implementation in hypothetical multi-level ADT syntax with the defs as extension methods:
sealed trait Response[+A] {
  case Success(value: A)
  case sealed trait NoValueResponse {
    case NoChange
    case Accepted
    case sealed trait Failure {
      case NoSuchElement(message: String)
      case IllegalArgument(message: String, cause: Option[Throwable] = None)
      case GenericFailure(message: String, cause: Option[Throwable] = None)
    }
  }
}

object Response {

  implicit class Ops[A](response: Response[A]) {
    def returnValue: Option[A] = response match {
      case Success(value) => value match {
        case _: Unit => None
        case _       => Option(value)
      }
      case _: NoValueResponse => None
    }
    def map[B](f: A => B): Response[B] = response match  {
      case Success(value) =>
        try {
          Success(f(value))
        } catch {
          case NonFatal(e) => GenericFailure(s"Failed to map service response with value: $value", Some(e))
        }
      case noValue: NoValueResponse => noValue
    }
    def flatMap[B](f: A => Response[B]): Response[B] = response match {
      case Success(value) =>f(value)
      case noValue: NoValueResponse => noValue
    }
  }
}

object Success {
  def empty: Success[Unit] = Success(())
}

object Failure 
  implicit class Ops(failure: Failure) {
    def message: String = failure match {
      case NoSuchElement(message) => message
      case IllegalArgument(message, _) => message
      case GenericFailure(message, _) => message
    }
  }
}

I believe we should examine more cases like this and consider whether they are worth the trouble.

2 Likes

The alternative (fixing the definition of Bounded) is:

sealed trait SizeInfo

object SizeInfo {
  enum Atomic extends SizeInfo {
    case Infinite
    case Precise(n: Int)
  }

  enum Bounded extends SizeInfo {
    case Bounded(lo: Atomic.Precise, hi: Atomic)
  }

  export Atomic.{ Infinite, Precise }

  def Bounded(lo: Precise, hi: Atomic): Bounded = Bounded.Bounded(lo, hi)
}
scala> val i = SizeInfo.Infinite
val i: SizeInfo.Atomic = Infinite

scala> val p = SizeInfo.Precise(1)
val p: SizeInfo.Atomic = Precise(1)

scala> val b = SizeInfo.Bounded(new SizeInfo.Precise(1), SizeInfo.Infinite)
val b: SizeInfo.Bounded = Bounded(Precise(1),Infinite)

Is there a reason you can’t export Bounded?

16 |  export Atomic.{ Infinite, Precise }, Bounded.Bounded
   |                                               ^^^^^^^
   |                             Bounded is already defined as class Bounded
2 Likes

How about just a case class for the single case enum?

sealed trait SizeInfo

object SizeInfo {
  enum Atomic extends SizeInfo {
    case Infinite
    case Precise(n: Int)
  }

  case class Bounded(lo: Atomic.Precise, hi: Atomic) extends SizeInfo

  export Atomic.{ Infinite, Precise }
}
1 Like

As once implemented graphql in Scala(not opensource), I think support this would be very great. as an open source reference :

The GraphQL’s ADT should be a good reference for that use-case.

4 Likes

The most elegant way to model (at least some of these cases) without multi-level enums that I have found so far is using union types:

  1. SizeInfo:
type SizeInfo = Atomic | Bounded

enum Atomic {
  case Infinite
  case Precise(n: Int)
}

case class Bounded(bound: Int)
  1. Generalization of Either to include “Inclusive OR”
type AndOr[+A, +B] = Both[A, B] | Either[A, B]

case class Both[+A, +B](left: A, right: B)

enum Either[+A, +B]:
  case Left[+A, +B](value: A) extends Either[A, B]
  case Right[+A, +B](value: B) extends Either[A, B]
  1. JSON:
type JsValue = Obj | Arr | Primitive

case class Obj(fields: Map[String, JsValue])
case class Arr(elems: Array[JsValue])

type Primitive = Str | Num | JsNull.type | Bool

case class Str(str: String)
case class Num(bigDecimal: BigDecimal)
case object JsNull

enum Bool(boolean: Boolean):
    case True extends Bool(true)
    case False extends Bool(false)

The second example from the original post (the Hierarchy of Reals/Rationals/Integers/Naturals) is more tricky using Union Types as the top-level type has a shared member.

2 Likes

For the Hierarchy of Reals/Rationals/Integers/Naturals example we can do something like:

type Real = Rational | Irrational
object Real:
  abstract class Type(computeDigits: Int => BigDecimal)

case class Irrational(computeDigits: Int => BigDecimal)
  extends Real.Type(computeDigits)

type Rational = Integer | Fraction
object Rational:
  abstract class Type(numerator: Int, denominator: Int)
    extends Real.Type(_ => BigDecimal(numerator) / denominator)

case class Fraction(numerator: Int, denominator: Int)
  extends Rational.Type(numerator, denominator)

enum Integer(n: Int) extends Rational.Type(n, 1):
  case Natural(n: Int) extends Integer(n)
  case Negative(n: Int) extends Integer(n)


val r1: Integer = Integer.Natural(4)
val r2: Integer = Integer.Negative(5)
val r3: Rational = Fraction(2, 4)
val r4: Irrational = Irrational(i => 2*i)

Here I introduced a common abstract class / trait for each union type that shares a common member and declare it inside an object with the same name as the union type.

As observed by @bjornregnell the union types with “shared members” relate to the discussion over at Making union types even more useful

2 Likes

Perhaps it’s time to un-earth this proposal? Seemed like a good idea at its time.

8 Likes

This proposal is severely underated to me.

There are a lot of common use cases (as illustrated by @joshlemer’s examples) and do not seem (at least to me) to break compatibility nor general language “style”.

The lack of multi-level hierarchy is a major blocker to me for adopting enum instead of sealed traits + case classes/objects and I suppose I am not the only one in this situation.

I guess the next step if someone has enough time is to make a SIP?

10 Likes

I would also like to say that I think this proposal would be very useful. I can think of many different times when I was coding where this feature would have come in handy.

Also it seems like it would be backwards compatible, so I see no reason not to add it.

7 Likes

This is just what I need to build clear and complete domain models using enums. Type systems (like JSON) and different protocols often need 2-3 levels of nested items, as shown above.

8 Likes

I came to this topic because I wanted to know how can I access the members of nested enum I wrote. I automatically assumed I can do so, my attempt was:

enum Outer:
  case One
  case Two
  enum Inner:
    case A
    case B

Off course, this does not do what I expected, because with current desugaring the Inner is really an inner path dependent enum, and it would define path-dependent values like:

Outer.Two.Inner.A.ordinal
Outer.One.Inner.A.ordinal

I agree such multi-level ADTs are quite common and having the enum functionality for them (esp. listing values) would be very handy.

3 Likes

The workaround using intermediate case is good, but you lose one handy feature this way, values method. Value enumeration comes handy in cases like:

enum Outer:
  case One
  case Two
  case enum Inner:
    case A
    case B
1 Like