Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Circe instances for encoding/decoding sealed trait instances of arity 0?

Tags:

scala

circe

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?

like image 839
Andrew Roberts Avatar asked May 03 '16 18:05

Andrew Roberts


1 Answers

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

like image 169
Travis Brown Avatar answered Oct 19 '22 02:10

Travis Brown