I'm using sealed traits as enums for exhaustive pattern matching. In cases where I have case objects instead of case classes extending my trait, I'd like to encode and decode (via Circe) as just a plain string.
For example:
sealed trait State
case object On extends State
case object Off extends State
val a: State = State.Off
a.asJson.noSpaces // trying for "Off"
decode[State]("On") // should be State.On
I understand that this will be configurable in 0.5.0, but can anyone help me write something to tide me over until that's released?
To highlight the problem—assuming this ADT:
sealed trait State
case object On extends State
case object Off extends State
circe's generic derivation will (currently) produce the following encodings:
scala> import io.circe.generic.auto._, io.circe.syntax._
import io.circe.generic.auto._
import io.circe.syntax._
scala> On.asJson.noSpaces
res0: String = {}
scala> (On: State).asJson.noSpaces
res1: String = {"On":{}}
This is because the generic derivation mechanism is built on Shapeless's LabelledGeneric
, which represents case objects as empty HList
s. This will probably always be the default behavior, since it's clean, simple, and consistent, but it's not always what you want (as you note the configuration options that are coming soon will support alternatives).
You can override this behavior by providing your own generic instances for case objects:
import io.circe.Encoder
import shapeless.{ Generic, HNil }
implicit def encodeCaseObject[A <: Product](implicit
gen: Generic.Aux[A, HNil]
): Encoder[A] = Encoder[String].contramap[A](_.productPrefix)
This says, "if the generic representation of A
is an empty HList
, encode it as its name as a JSON string". And it works as we'd expect for case objects that are statically typed as themselves:
scala> On.asJson.noSpaces
res2: String = "On"
When the value is statically typed as the base type, the story is a little different:
scala> (On: State).asJson.noSpaces
res3: String = {"On":"On"}
We get a generically derived instance for State
, and it respects our manually defined generic instance for case objects, but it still wraps them in an object. This makes some sense if you think about it—the ADT could contain case classes, which can only reasonably be represented as a JSON object, and so the object-wrapper-with-constructor-name-key approach is arguably the most reasonable thing to do.
It's not the only thing we can do, though, since we do know statically whether the ADT contains case classes or only case objects. First we need a new type class that witnesses that an ADT is made up only of case objects (note that I'm assuming a fresh start here, but it should be possible to make this work alongside generic derivation):
import shapeless._
import shapeless.labelled.{ FieldType, field }
trait IsEnum[C <: Coproduct] {
def to(c: C): String
def from(s: String): Option[C]
}
object IsEnum {
implicit val cnilIsEnum: IsEnum[CNil] = new IsEnum[CNil] {
def to(c: CNil): String = sys.error("Impossible")
def from(s: String): Option[CNil] = None
}
implicit def cconsIsEnum[K <: Symbol, H <: Product, T <: Coproduct](implicit
witK: Witness.Aux[K],
witH: Witness.Aux[H],
gen: Generic.Aux[H, HNil],
tie: IsEnum[T]
): IsEnum[FieldType[K, H] :+: T] = new IsEnum[FieldType[K, H] :+: T] {
def to(c: FieldType[K, H] :+: T): String = c match {
case Inl(h) => witK.value.name
case Inr(t) => tie.to(t)
}
def from(s: String): Option[FieldType[K, H] :+: T] =
if (s == witK.value.name) Some(Inl(field[K](witH.value)))
else tie.from(s).map(Inr(_))
}
}
And then our generic Encoder
instances:
import io.circe.Encoder
implicit def encodeEnum[A, C <: Coproduct](implicit
gen: LabelledGeneric.Aux[A, C],
rie: IsEnum[C]
): Encoder[A] = Encoder[String].contramap[A](a => rie.to(gen.to(a)))
Might as well go ahead and write the decoder too.
import cats.data.Xor, io.circe.Decoder
implicit def decodeEnum[A, C <: Coproduct](implicit
gen: LabelledGeneric.Aux[A, C],
rie: IsEnum[C]
): Decoder[A] = Decoder[String].emap { s =>
Xor.fromOption(rie.from(s).map(gen.from), "enum")
}
And then:
scala> import io.circe.jawn.decode
import io.circe.jawn.decode
scala> import io.circe.syntax._
import io.circe.syntax._
scala> (On: State).asJson.noSpaces
res0: String = "On"
scala> (Off: State).asJson.noSpaces
res1: String = "Off"
scala> decode[State](""""On"""")
res2: cats.data.Xor[io.circe.Error,State] = Right(On)
scala> decode[State](""""Off"""")
res3: cats.data.Xor[io.circe.Error,State] = Right(Off)
Which is what we wanted.