scalaserializationcircescala-3

(De)serialize enum as string in Scala 3


I am trying to find a simple and efficient way to (de)serialize enums in Scala 3 using circe.

Consider the following example:

import io.circe.generic.auto._
import io.circe.syntax._

enum OrderType:
  case BUY
  case SELL

case class Order(id: Int, `type`: OrderType, amount: String)
val order = Order(1, OrderType.SELL, "123.4")
order.asJson

On serializing the data, it becomes

{
  "id" : 1,
  "type" : {
    "SELL" : {
      
    }
  },
  "amount" : "123.4"
}

instead of

{
  "id" : 1,
  "type" : "SELL",
  "amount" : "123.4"
}

which is what I want.

I know that I can write a custom (de)serializer for this which will solve the issue for this particular instance like this:

implicit val encodeOrderType: Encoder[OrderType] = (a: OrderType) =>
  Encoder.encodeString(a.toString)
  
implicit def decodeOrderType: Decoder[OrderType] = (c: HCursor) => for {
  v <- c.as[String]
} yield OrderType.valueOf(v)

but I was looking for a generic solution that might work for any enum.

EDIT 1

One way of doing serialization, (deserialization does not work :/) is to make all enums extend a common trait and define encoder for all enums extending it. For the above example, it looks something like this.

trait EnumSerialization

enum OrderType extends EnumSerialization:
  case BUY
  case SELL
  
enum MagicType extends EnumSerialization:
  case FIRE
  case WATER
  case EARTH
  case WIND

implicit def encodeOrderType[A <: EnumSerialization]: Encoder[A] = (a: A) => Encoder.encodeString(a.toString)

// This correctly serializes all instances of enum into a string
case class Order(id: Int, `type`: OrderType, amount: String)
val order     = Order(1, OrderType.SELL, "123.4")
val orderJson = order.asJson
// Serializes to { "id" : 1, "type" : "SELL", "amount" : "123.4"}

case class Magic(id: Int, magic: MagicType)
val magic = Magic(3, MagicType.WIND)
val magicJson = magic.asJson
// Serializes to { "id" : 3, "magic" : "WIND"}

However this does not extend to deserialization.


Solution

  • In Scala 3 you can use Mirrors to do the derivation directly:

    import io.circe._
    
    import scala.compiletime.summonAll
    import scala.deriving.Mirror
    
    inline def stringEnumDecoder[T](using m: Mirror.SumOf[T]): Decoder[T] =
      val elemInstances = summonAll[Tuple.Map[m.MirroredElemTypes, ValueOf]]
        .productIterator.asInstanceOf[Iterator[ValueOf[T]]].map(_.value)
      val elemNames = summonAll[Tuple.Map[m.MirroredElemLabels, ValueOf]]
        .productIterator.asInstanceOf[Iterator[ValueOf[String]]].map(_.value)
      val mapping = (elemNames zip elemInstances).toMap
      Decoder[String].emap { name =>
        mapping.get(name).fold(Left(s"Name $name is invalid value"))(Right(_))
      }
    
    inline def stringEnumEncoder[T](using m: Mirror.SumOf[T]): Encoder[T] =
      val elemInstances = summonAll[Tuple.Map[m.MirroredElemTypes, ValueOf]]
        .productIterator.asInstanceOf[Iterator[ValueOf[T]]].map(_.value)
      val elemNames = summonAll[Tuple.Map[m.MirroredElemLabels, ValueOf]]
        .productIterator.asInstanceOf[Iterator[ValueOf[String]]].map(_.value)
      val mapping = (elemInstances zip elemNames).toMap
      Encoder[String].contramap[T](mapping.apply)
    
    enum OrderType:
      case BUY
      case SELL
    object OrderType:
      given decoder: Decoder[OrderType] = stringEnumDecoder[OrderType]
      given encoder: Encoder[OrderType] = stringEnumEncoder[OrderType]
    end OrderType
    
    import io.circe.syntax._
    import io.circe.generic.auto._
    
    case class Order(id: Int, `type`: OrderType, amount: String)
    val order = Order(1, OrderType.SELL, "123.4")
    order.asJson
    // {
    //   "id" : 1,
    //   "type" : "SELL",
    //   "amount" : "123.4"
    // }: io.circe.Json
    

    It uses inline and Mirror to

    It only works with enums made of case objects and it would fail if any case stored some nested data - for that use standard derivation procedure with discriminator fields or nested structure named after the nested type.

    You could import stringEnumDecoder and stringEnumEncoder and make them given, although I would prefer to add them manually, since they are more of an exception than a rule.