top of page
Search

Sealed Traits Vs Enums for ADTs

By Kateu Herbert


1. Introduction

Since the introduction of Scala 3, there have been several interesting new features that have been welcomed by the community. Enumerations were one of the long-awaited features, this is a data structure that's used to define a type consisting of a set of features.


For this article, I'd like to talk about my experience with enums vs sealed traits for structuring ADTs and some of the challenges I faced when transitioning to enums for this implementation.


In the following examples, we try to structure an ADT to represent some errors. We'll be using Cats and Cats Effect throughout the article.


2. Enum ADTs and Error Accumulation

One of the things to love about enums is how you can easily carry out error accumulation even when the underlying type is an Option.


import cats.syntax.all.*

object ExistingCode:
  case class Age(value: Int)
  object Age:
    def apply(value: Int): Option[Age] =
      (value > 25).guard[Option].as(new Age(value))

  case class Height(value: Double)
  object Height:
    def apply(value: Double): Option[Height] =
      (value > 5.0).guard[Option].as(new Height(value))


Here we have two case classes, Age which takes an Int representing years, and Height which takes a Double representing feet in the apply() method. We require the years in Age to be 25 or more and the feet in Height to be 5.0 or more to create a Some, other wise a None will be produced.


Let's assume we have been hired to validate these values coming in from an online form, and we need to implement error accumulation for better feedback to the user by adding to the existing code available. Naturally, we start by creating our ADT.


object Adts:
  import ExistingCode.*

  enum DetailsError(val err: String):
    case AgeError extends DetailsError("Required age is 25 years or more")
    case HeightError extends DetailsError("Required height is 5 feet or more.")


DetailsError has two error cases, AgeError and HeightError, both with appropriate error messages available.


import cats.data.ValidatedNec

object Adts:
  ...
  case class Details(age: Age, height: Height)
  object Details:
    import DetailsError.*
    def apply(age: Int, height: Double): ValidatedNec[DetailsError, Details] =
      (
        Age(age).toValidNec(AgeError),
        Height(height).toValidNec(HeightError)
      ).mapN(new Details(_, _))


Here we capture our data in a Details case class. We use the toValidNec() method on Age and Height passing in the appropriate error then use mapN() to construct Details with the corresponding values. If an invalid value is passed to the apply() method, the errors would be accumulated in a NonEmptyChain for further processing.



import cats.effect.{IOApp, IO}
import cats.data.Validated.{Valid, Invalid}

object AdtProgram extends IOApp.Simple:
  import Adts3.*
  override def run: IO[Unit] =
    Details(24, 4.9) match
      case Valid(a)   => IO.println(a)
      case Invalid(e) => IO.println(e.toChain)

  /**
    * Chain(AgeError, HeightError)
    */


When we run our program with inputs that don't meet the requirements we get both error messages. The enum method for creating ADTs shines because no edits were made to Age and Height, the logic for error accumulation was transferred to the Details companion object with the help of the toValidNec() method.



3. Sealed Trait ADTs and Error Accumulation

Here we'll try to add error accumulation just like before without touching the existing code.


object Adts2:
  import ExistingCode.*
  sealed trait DetailsError:
    val errMsg: String
  object DetailsError:
    case object AgeError extends DetailsError:
      override val errMsg: String = "Required age is 25 years or more"
    case object HeightError extends DetailsError:
      override val errMsg: String = "Required height is 5 feet or more."


We now have our ADT, DetailsError created using a sealed trait and case objects in the companion object.


object Adts2:
  ...
  case class Details(age: Age, height: Height)
  object Details:
    import DetailsError.*
    def apply(age: Int, height: Double): ValidatedNec[DetailsError, Details] =
      (
        Age(age).toValidNec(AgeError),
        Height(height).toValidNec(HeightError)
      ).mapN(new Details(_, _))


Here we run into our first error:



No given instance of type cats.Semigroupal[
  [X0] =>>
    cats.data.Validated[
      cats.data.NonEmptyChainImpl.Type[
        com.xonal.adts.Adts2.DetailsError.AgeError.type] |
        cats.data.NonEmptyChainImpl.Type[
          com.xonal.adts.Adts2.DetailsError.HeightError.type]
      ,
    X0]
] was found for parameter semigroupal of method mapN in class Tuple2SemigroupalOps.
```

The compiler needs a given instance for a Semigroupal of the above type. Here is how the signature would look:



    given smg: Semigroupal[[A] =>> Validated[
      NonEmptyChain[AgeError.type] | NonEmptyChain[HeightError.type],
      A
    ]] with
      override def product[A, B]
      (fa: Validated[Type[AgeError.type] | Type[HeightError.type], A], 
      fb: Validated[Type[AgeError.type] | Type[HeightError.type], B]):
         Validated[Type[AgeError.type] | Type[HeightError.type], (A, B)] = ???



The implementation makes heavy use of union types, because of the nature of product, the resulting value will tuple our success values, `(A,B)`, in this case, it would be better to create an instance of Apply which still extends Semigroupal but provides a better type signature, here's the whole code:



import cats.Apply
import cats.data.NonEmptyChainImpl.Type

object Adts2:
  ...
  object Details:
    ...
    given smg: Apply[[A] =>> Validated[
      NonEmptyChain[AgeError.type] | NonEmptyChain[HeightError.type],
      A
    ]] with
      override def ap[A, B](
        ff: Validated[Type[AgeError.type] | Type[HeightError.type], A => B]
      )(
        fa: Validated[Type[AgeError.type] | Type[HeightError.type], A]
      ): Validated[Type[AgeError.type] | Type[HeightError.type], B] =
        (fa, ff) match
          case (Valid(a), Valid(fab))     => Valid(fab(a))
          case (i @ Invalid(_), Valid(_)) => i
          case (Valid(_), i @ Invalid(_)) => i
          case (Invalid(e1), Invalid(e2)) =>
            Invalid(
              e2.combineK(e1)
                .asInstanceOf[Type[AgeError.type] | Type[HeightError.type]]
            )


We now override ap() to produce a Validated[Type[AgeError.type] | Type[HeightError.type], B]`. Even after implementing this we still got another error:


object creation impossible, since def map[A, B](fa: F[A])(f: A => B): F[B] in trait Functor in package cats is not defined

```

We can implement `map()` as follows:


object Adts2:
  ...
  object Details:
    ...
    given smg: Apply[[A] =>> Validated[
      NonEmptyChain[AgeError.type] | NonEmptyChain[HeightError.type],
      A
    ]] with
      ...
      override def map[A, B](
        fa: Validated[Type[AgeError.type] | Type[HeightError.type], A]
      )(f: A => B): Validated[Type[AgeError.type] | Type[HeightError.type], B] =
        fa match
          case Valid(a)       => Valid(f(a))
          case i @ Invalid(e) => i


After all this we finally get error accumulation working.



object AdtProgram extends IOApp.Simple:
  import Adts2.*
  override def run: IO[Unit] =
    Details(24, 4.9) match
      case Valid(a)   => IO.println(a)
      case Invalid(e) => IO.println(e.toChain)

  /** 
    * Chain(AgeError, HeightError)
    */

This is a lot of boilerplate to get error accumulation working.

If the ExistingCode was written with error accumulation in mind, the Age and Height apply() methods would have a type that enables error accumulation such as EitherNel, EitherNec, ValidatedNel, and ValidatedNec, this would avoid the need for Apply or Semigroupal type instances.


3. Using ValidatedNec for Case Classes

In this section we'll update the Age and Height` `apply() methods to return a ValidatedNec instead of Option:



object ExistingCode2:
  sealed trait DetailsError:
    val errMsg: String
  object DetailsError:
    case object AgeError extends DetailsError:
      override val errMsg: String = "Required age is 25 years or more"
    case object HeightError extends DetailsError:
      override val errMsg: String = "Required height is 5 feet or more."

  import DetailsError.* 
  case class Age(value: Int)
  object Age:
    def apply(value: Int): ValidatedNec[DetailsError, Age] =
      Validated.condNec(
        value > 25,
        new Age(value),
        AgeError
      )

  case class Height(value: Double)
  object Height:
    def apply(value: Double): ValidatedNec[DetailsError, Height] =
      Validated.condNec(
        value > 5.0,
        new Height(value),
        HeightError
      )

Here we replace the implementation of the apply() methods with Validated.condNec which takes a predicate, the success value, and the failure value. The trick is to have ValidatedNec[DetailsError, _] as the return type where DetailsError is used instead of AgeError or HeightError.


object Adts3:
  import ExistingCode2.*
  case class Details(age: Age, height: Height)
  object Details:
    def apply(age: Int, height: Double): ValidatedNec[DetailsError, Details] =
      (
        Age(age),
        Height(height)
      ).mapN(new Details(_, _))

Now our code compiles without fighting the compiler. Having our code structured this way also works with enums out of the box:


object ExistingCode3:
  enum DetailsError(val err: String):
    case AgeError extends DetailsError("Required age is 25 years or more")
    case HeightError extends DetailsError("Required height is 5 feet or more.")

  import DetailsError.*
  case class Age(value: Int)
  object Age:
    def apply(value: Int): ValidatedNec[DetailsError, Age] =
      Validated.condNec(
        value > 25,
        new Age(value),
        AgeError
      )

  case class Height(value: Double)
  object Height:
    def apply(value: Double): ValidatedNec[DetailsError, Height] =
      Validated.condNec(
        value > 5.0,
        new Height(value),
        HeightError
      )

```

Finally:

object Adts4:
  import ExistingCode3.*
  case class Details(age: Age, height: Height)
  object Details:
    def apply(age: Int, height: Double): ValidatedNec[DetailsError, Details] =
      (
        Age(age),
        Height(height)
      ).mapN(new Details(_, _))

Enums for ADTs earn some extra points for flexibility.


4. Nested Case Classes and Sealed Trait ADTs

Let's assume we have another case class PersonsDetails that contains Details as one of its elements, we want to merge all errors from Details with those from the rest of the elements:


object Adts5:
  import ExistingCode2.DetailsError
  import Adts3.*

  case class RelationshipStatus(value: String)
  object RelationshipStatus:
    def apply(value: String): ValidatedNec[DetailsError, RelationshipStatus] =
      Validated.condNec(
        value == "married" || value == "single",
        new RelationshipStatus(value),
        RelationshipStatusError
      )

  case class PersonDetails(
    relationshipStatus: RelationshipStatus,
    details: Details
  )
  object PersonDetails:
    def apply(
      relationshipStatus: String,
      age: Int,
      height: Double
    ): ValidatedNec[DetailsError, PersonDetails] =
      (
        RelationshipStatus(relationshipStatus),
        Details(age, height)
      ).mapN(new PersonDetails(_, _))


PersonDetails consists of a RelationshipStatus and Details. RelationshipStatus is defined as a case class whose apply() method checks to see if married or single has been passed in as a value to produce a ValidatedNec[DetailsError, RelationshipStatus].


The question is now how can we create RelationshipError as a child of DetailsError? Because we are using sealed traits we can easily accomplish this by creating another child case object and extending DetailsError:


object Adts5:
  import ExistingCode2.DetailsError
  import Adts3.*

  case object RelationshipStatusError extends DetailsError:
    override val errMsg: String = "Must be either single or married"
  ...

When we run our code, error accumulation works:

object AdtProgram extends IOApp.Simple:
  import Adts5.*
  override def run: IO[Unit] =
    PersonDetails("available", 24, 4.5) match
      case Valid(a)   => IO.println(a)
      case Invalid(e) => IO.println(e.toChain)
  /**
   * Chain(RelationshipStatusError, AgeError, HeightError)
   */

Let's see if we can also accomplish this with enums


5. Nested Case Classes and Enum ADTs


object Adts6:
  import ExistingCode3.DetailsError
  import Adts4.*

  case class RelationshipStatus(value: String)
  object RelationshipStatus:
    def apply(value: String): ValidatedNec[DetailsError, RelationshipStatus] =
      Validated.condNec(
        value == "married" || value == "single",
        new RelationshipStatus(value),
        RelationshipStatusError
      )

  case class PersonDetails(
    relationshipStatus: RelationshipStatus,
    details: Details
  )
  object PersonDetails:
    def apply(
      relationshipStatus: String,
      age: Int,
      height: Double
    ): ValidatedNec[DetailsError, PersonDetails] =
      (
        RelationshipStatus(relationshipStatus),
        Details(age, height)
      ).mapN(new PersonDetails(_, _))

Here we are importing from ExistingCode3 that contains our DetailsError implemented as an enum. Let's also define RelationshipStatusError also as an enum:


object Adts6:
  import ExistingCode3.*
  import Adts4.*

  enum OtherError(val err: String):
    case RelationshipStatusError extends OtherError("Must be either single or married")
  ...


The next challenge is to somehow describe OtherError as a child type to DetailsError.


However, enums can't extend other enums. Even if we nested the enums, the compiler would not recognize the nested enum as a child type.


To get our code compiling we can replace DetailsError with OtherError in the apply() method of RelationshipStatus as follows:



  object RelationshipStatus:
    def apply(value: String): ValidatedNec[OtherError, RelationshipStatus] =
      Validated.condNec(
        value == "married" || value == "single",
        new RelationshipStatus(value),
        RelationshipStatusError
      )


However now in the apply() method of PersonDetails we have a very familiar error message;


No given instance of type cats.Semigroupal[
  [X0] =>>
    cats.data.Validated[
      cats.data.NonEmptyChainImpl.Type[adts.Adts6.OtherError] |
        cats.data.NonEmptyChainImpl.Type[adts.ExistingCode3.DetailsError],
    X0]
] was found for parameter semigroupal of method mapN in class Tuple2SemigroupalOps..


Now we could implement a given Apply instance for the above type, similar to what we did previously, however there's another solution that involves the use of union types:



  object RelationshipStatus:
    def apply(value: String): ValidatedNec[OtherError | DetailsError, RelationshipStatus] =
      Validated.condNec(
        value == "married" || value == "single",
        new RelationshipStatus(value),
        RelationshipStatusError
      )

  object PersonDetails:
    def apply(
      relationshipStatus: String,
      age: Int,
      height: Double
    ): ValidatedNec[OtherError | DetailsError, PersonDetails] =
      (
        RelationshipStatus(relationshipStatus),
        Details(age, height)
      ).mapN(new PersonDetails(_, _))

Having both apply() methods for RelationshipStatus and PersonDetails return OtherError | DetailsError as the error channel to ValidatedNec makes our code compile:


object AdtProgram extends IOApp.Simple:
  import Adts6.*
  override def run: IO[Unit] =
    PersonDetails("available", 24, 4.5) match
      case Valid(a)   => IO.println(a)
      case Invalid(e) => IO.println(e.toChain)
/**
 * Chain(RelationshipStatusError, AgeError, HeightError)
 */

This solution is more of a hack because it makes no sense to have ValidatedNec[OtherError | DetailsError, RelationshipStatus] as the return type to the RelationshipStatus apply() method.


This solution would confuse someone new looking at our code for the first time. Therefore enums lose points when it comes to nested case classes.


Another obvious solution would be to go into ExistingCode3 and add RelationshipStatusError as another case to `DetailsError` but this defeats the purpose of extensability in code bases.


6. Conclusion

We've covered error accumulation with ADTs implemented with both sealed traits and enums, they both work well in different scenarios and one is not better than the other. Sealed traits are a good option for nested ADTs and enums for non nested ADTs. There have been some discussions to improve on enums in this regard. I will personally be using sealed traits until enums can be extended or nested.


I'd love to hear your opinions on the topic. The code for this article can be found over on GitHub.


_________


Thank you so much to Kateu for submitting a blog to Scala Matters! Your contribution to the Scala community means a lot to us.


If you would like to submit a blog or a video, contact Patrycja or email Patrycja@umatr.io


Alternatively, follow Scala Matters and UMATR for more Scala blogs and updates!

bottom of page