Decode “flat” ADTs with Circe

Written by

I had to talk to a web-service that returned JSON without meaningful HTTP status codes (don’t ask). All I had was the JSON itself to figure out whether the service returned a success or failure: Essentially I had to decode an ADT except that all alternatives were flattened into the same level in the JSON. A Circe decoder for these responses needed to inspect the JSON while decoding to pick the right branch.

In case of success this service returned something like the following object:

{
  "type": "record",
  "id": 42,
  "version": 1
}

which corresponds to the following model:

import io.circe._
import io.circe.generic.semiauto._

sealed trait Response

final case class Success(`type`: String, id: Int, version: Int)
  extends Response

object Success {
  implicit val successDecoder: Decoder[Success] = deriveDecoder[Success]
}

In case of error I got this from the service:

{
  "error": "Some error message",
  "status": 409
}

The corresponding model looks as follows:

final case class Error(error: String, status: Int)
  extends Response

object Error {
  implicit val errorDecoder: Decoder[Error] = deriveDecoder[Error]
}

In both cases I can have Circe automatically derive the Decoder instances. I can also distinguish between both JSON shapes by looking at the presence of the error field: If it exists I have an error, otherwise a successful response. But this way of decoding goes beyond Circe’s derivation capabilities, so I had to come up with my own Decoder[Response] implementation:

object Response {
  implicit def responseDecoder(
    implicit decodeError: Decoder[Error],
    decodeSuccess: Decoder[Success]
  ): Decoder[Response] = new Decoder[Response] {
    override def apply(cursor: HCursor): Result[Response] =
      if (cursor.downField("error").succeeded) {
        decodeError(cursor)
      } else {
        decodeSuccess(cursor)
      }
  }
}

My Decoder uses the cursor API (see Traversing and modifying JSON) to check whether the error field exists: I try to move down to the error field and check whether the operation succeeded—if it did the field exists, otherwise it is missing. In first case I delegate to the Error decoder, in the second to the Response decoder.

Decoding a JSON from the service can now tell me whether I have received a successful response:

import io.circe.parser.decode

decode[Response](jsonString) match {
  case Right(success : Success) => log.info(s"Received $success")
  case Right(error: Error) => log.error(s"Received an error: $error")
  case Left(decodingFailure) => throw decodingFailure
}