Search code examples
scalacirce

How to create custom encoding of Option types with Circe?


It's possible to have a class that looks like so:

case class Amount(value: Int)
case class Data(insurance: Option[Amount], itemPrice: Amount)

If insurance = None it should get a default value of waived: true

E.g:

Data(Some(123),100).asJson

// output
{
  "insurance": {
    "value": 123
  },
  "price": 100
}

And when no Insurance is opted for:

Data(None,100).asJson

// output
{
  "insurance": {
    "waived: true
  },
  "price": 100
}

How can this fine-grained control be achieved? I tried various tricks with forProduct2 and mapJsonObject but couldn't get it to behave right:

implicit val testEncoder = deriveEncoder[Option[Amount]].mapJsonObject(j => {

    val x = j("Some") match {
      case Some(s) => // need to convert to [amount -> "value"]
      case None => JsonObject.apply(("waived",Json.fromBoolean(true)))
    }

    x
  })

This can easily get me the waived:true part but no idea how to handle the Some(s) case.


Solution

  • If having {"waived": true} is expected behavior for any Option[Amount] if it's None, then you can rely on semiauto derived encoders if you write your custom encoder for Option[Amount]

    Here is an example

    import io.circe.{Encoder, Json}
    import io.circe.syntax._
    import io.circe.generic.semiauto._
    
    case class Amount(value: Int)
    case class Data(insurance: Option[Amount], itemPrice: Amount)
    
    object Amount {
      implicit val encoder: Encoder[Amount] = deriveEncoder
    }
    
    object Data {
      implicit val encoderOptionalAmount: Encoder[Option[Amount]] = (optA: Option[Amount]) =>
          optA match {
            case Some(amount) => amount.asJson
            case None => Json.obj("waived" -> true.asJson)
          }
    
      implicit val encoder: Encoder[Data] = deriveEncoder[Data]
    }
    
    println(Data(insurance = None, itemPrice = Amount(10)).asJson)
    
    /*
    {
      "insurance" : {
        "waived" : true
      },
      "itemPrice" : {
        "value" : 10
      }
    }
    */
    

    How it works: deriveEncoder[Data] will call implicit encoders for both itemPrice (of type Amount) and insurance of type Option[Amount].

    Default encoder for Option[T] just skips the value if it's None but since we defined another implicit encoder for Option[T] in the closest scope (Data object-companion) it won't look for implicit encoders in global scopes giving you exactly what you want.