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

前端 未结 1 1885
清歌不尽
清歌不尽 2020-12-25 08:39

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 de

相关标签:
1条回答
  • 2020-12-25 09:14

    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.

    0 讨论(0)
提交回复
热议问题