Proposal for Multi-level Enums

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