(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.


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

  • get a list of types of sum type elements
  • get a list of labels of these types
  • obtain their values to create Map[String, T] or Map[T, String]
  • emap/contramap String codecs so that translation wouldn't be nested nor require discriminating field

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.


There is a library that helps you with this: circe-tagged-adt-codec-scala3

With this your code looks like

import org.latestbit.circe.adt.codec._

enum OrderType derives JsonTaggedAdt.Encoder:
  case BUY
  case SELL
  
enum MagicType derives JsonTaggedAdt.Encoder:
  case FIRE
  case WATER
  case EARTH
  case WIND