(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]
orMap[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 object
s 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