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