Search code examples
jsonscalacircegeneric-derivation

Encoding ADT case classes with a discriminator, even when typed as the case class


Suppose I have a ADT in Scala:

sealed trait Base
case class Foo(i: Int) extends Base
case class Baz(x: String) extends Base

I want to encode values of this type into the JSON that looks like the following:

{ "Foo": { "i": 10000 }}
{ "Baz": { "x": "abc" }}

Which luckily is exactly the encoding circe's generic derivation provides!

scala> import io.circe.generic.auto._, io.circe.syntax._
import io.circe.generic.auto._
import io.circe.syntax._

scala> val foo: Base = Foo(10000)
foo: Base = Foo(10000)

scala> val baz: Base = Baz("abc")
baz: Base = Baz(abc)

scala> foo.asJson.noSpaces
res0: String = {"Foo":{"i":10000}}

scala> baz.asJson.noSpaces
res1: String = {"Baz":{"x":"abc"}}

The problem is that the encoder circe uses depends on the static type of the expression we're encoding. This means that if we try to decode one of the case classes directly, we lose the discriminator:

scala> Foo(10000).asJson.noSpaces
res2: String = {"i":10000}

scala> Baz("abc").asJson.noSpaces
res3: String = {"x":"abc"}

…but I want the Base encoding even when the static type is Foo. I know I can define explicit instances for all of the case classes, but in some cases I might have a lot of them, and I don't want to have to enumerate them.

(Note that this is a question that's come up a few times—e.g. here.)


Solution

  • It is possible to do this fairly straightforwardly by defining an instance for subtypes of the base type that just delegates to the Base decoder:

    import cats.syntax.contravariant._
    import io.circe.ObjectEncoder, io.circe.generic.semiauto.deriveEncoder
    
    sealed trait Base
    case class Foo(i: Int) extends Base
    case class Baz(x: String) extends Base
    
    object Base {
      implicit val encodeBase: ObjectEncoder[Base] = deriveEncoder
    }
    
    object BaseEncoders {
      implicit def encodeBaseSubtype[A <: Base]: ObjectEncoder[A] = Base.encodeBase.narrow
    }
    

    It works as expected:

    scala> import BaseEncoders._
    import BaseEncoders._
    
    scala> import io.circe.syntax._
    import io.circe.syntax._
    
    scala> Foo(10000).asJson.noSpaces
    res0: String = {"Foo":{"i":10000}}
    
    scala> (Foo(10000): Base).asJson.noSpaces
    res1: String = {"Foo":{"i":10000}}
    

    Unfortunately encodeBaseSubtype can't be defined in the Base companion object, since then it'd be picked up by the deriveEncoder macro, resulting in a cyclic definition (and stack overflows, etc.). I think I came up with a kind of horrible workaround for this problem at some point—I'll try to find it and post it as another answer if I do.