Search code examples
jsonscalaalgebraic-data-typescirce

Circe: decode multi-level ADT efficiently


I want to decode the following ADT with Circe:

sealed trait PaymentType
object PaymentType extends EnumEncoder[PaymentType] {
  case object DebitCard extends PaymentType
  case object Check     extends PaymentType
  case object Cash      extends PaymentType
  case object Mobile    extends PaymentType
}
sealed trait CreditCard extends PaymentType
object CreditCard extends EnumEncoder[CreditCard] {
  case object UNKNOWN_CREDIT_CARD extends CreditCard
  case object NOT_ACCEPTED        extends CreditCard
  case object VISA                extends CreditCard
  case object MASTER_CARD         extends CreditCard
  case object DINERS_CLUB         extends CreditCard
  case object AMERICAN_EXPRESS    extends CreditCard
  case object DISCOVER_CARD       extends CreditCard
}

As you can see, there is a parent type PaymentType, which has some direct inheritors and another sealed trait family CreditCard. Now decoding is done like this:

object CreditCard {
  implicit val decoder: Decoder[CreditCard] = Decoder.instance[CreditCard] {
  _.as[String].map {
    case "NOT_ACCEPTED"     => NOT_ACCEPTED
    case "VISA"             => VISA
    case "MASTER_CARD"      => MASTER_CARD
    case "DINERS_CLUB"      => DINERS_CLUB
    case "AMERICAN_EXPRESS" => AMERICAN_EXPRESS
    case "DISCOVER_CARD"    => DISCOVER_CARD
    case _                  => UNKNOWN_CREDIT_CARD
  }
}

object PaymentType {
  implicit val decoder: Decoder[PaymentType] = Decoder.instance[PaymentType] {
    _.as[String].flatMap {
      case "DebitCard" => Right(DebitCard)
      case "Check"     => Right(Check)
      case "Cash"      => Right(Cash)
      case "Mobile"    => Right(Mobile)
      case _           => Left(DecodingFailure("", List()))
    }
  }.or(CreditCard.decoder.widen)
}

What I don't like is the PaymentType decoder, particularly the fact that I need to create an extra and unnecessary instance of DecodingFailure in completely normal scenario when one encounters credit card-based payment type. We already spend 99.9% of CPU on JSON processing, and it just doesn't look right. Either it's bad ADT design, or there should be a way in Circe to handle this better. Any ideas?


Solution

  • You can move the fallback to the CreditCard decoder into the PaymentType decoder cases, which lets you avoid having to fail:

    implicit val decoder: Decoder[PaymentType] = Decoder.instance[PaymentType] { c =>
      c.as[String].flatMap {
        case "DebitCard" => Right(DebitCard)
        case "Check"     => Right(Check)
        case "Cash"      => Right(Cash)
        case "Mobile"    => Right(Mobile)
        case _           => CreditCard.decoder(c)
      }
    }
    

    In a case like this, though, I'd probably factor out the string parsing into separate methods:

    sealed trait PaymentType
    object PaymentType extends EnumEncoder[PaymentType] {
      case object DebitCard extends PaymentType
      case object Check     extends PaymentType
      case object Cash      extends PaymentType
      case object Mobile    extends PaymentType
    
      private val nameMapping = List(DebitCard, Check, Cash, Mobile).map(pt =>
        pt.productPrefix -> pt
      ).toMap
    
      def fromString(input: String): Option[PaymentType] = nameMapping.get(input)
    }
    
    sealed trait CreditCard extends PaymentType
    object CreditCard extends EnumEncoder[CreditCard] {
      case object UNKNOWN_CREDIT_CARD extends CreditCard
      case object NOT_ACCEPTED        extends CreditCard
      case object VISA                extends CreditCard
      case object MASTER_CARD         extends CreditCard
      case object DINERS_CLUB         extends CreditCard
      case object AMERICAN_EXPRESS    extends CreditCard
      case object DISCOVER_CARD       extends CreditCard
    
      private val nameMapping = List(
        NOT_ACCEPTED,
        VISA,
        MASTER_CARD,
        DINERS_CLUB,
        AMERICAN_EXPRESS,
        DISCOVER_CARD
      ).map(pt => pt.productPrefix -> pt).toMap
    
      def fromString(input: String): CreditCard =
        nameMapping.getOrElse(input, UNKNOWN_CREDIT_CARD)
    }
    

    Then you can write the decoders in terms of the fromString methods, which just feels like a better way of chopping up the problem to me (off the top of my head I'm not sure which approach will involve fewer allocations). That's probably largely a matter of taste, though.