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

1

7

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

Clinton answered 17/10, 2018 at 13:47 Comment(0)
C
5

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.

Clinton answered 17/10, 2018 at 13:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.