scalacircerefined

Scala circe deriveUnwrapped value class doesn't work for missing member


I am trying to decode a String value class in which if the string is empty I need to get a None otherwise a Some. I have the following ammonite script example:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._, io.circe.generic.JsonCodec
import $ivy.`io.circe::circe-generic-extras:0.13.0`, io.circe.generic.extras._, io.circe.generic.extras.semiauto._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

final case class CustomString(value: Option[String]) extends AnyVal
final case class TestString(name: CustomString)

implicit val customStringDecoder: Decoder[CustomString] =
    deriveUnwrappedDecoder[CustomString].map(ss => CustomString(ss.value.flatMap(s => Option.when(s.nonEmpty)(s))))

implicit val customStringEncoder: Encoder[CustomString] = deriveUnwrappedEncoder[CustomString]
implicit val testStringCodec: Codec[TestString] = io.circe.generic.semiauto.deriveCodec

val testString = TestString(CustomString(Some("test")))
val emptyTestString = TestString(CustomString(Some("")))
val noneTestString = TestString(CustomString(None))
val nullJson = """{"name":null}"""
val emptyJson = """{}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(emptyTestString.asJson.noSpaces == """{"name":""}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)

assert(decode[TestString](nullJson).exists(_ == noneTestString)) // this passes
assert(decode[TestString](emptyJson).exists(_ == noneTestString)) // this fails

Solution

  • The answers that exist don't solve the problem so here's the solution. If you don't want to use refined, you can define the decoder like so:

    implicit val customStringDecoder: Decoder[CustomString] =
      Decoder
        .decodeOption(deriveUnwrappedDecoder[CustomString])
        .map(ssOpt => CustomString(ssOpt.flatMap(_.value.flatMap(s => Option.when(s.nonEmpty)(s)))))
    

    However, if you use refined types (which I recommend) it can be even simpler by using the circe-refined and it comes with the benefit of better type safety(i.e. you know that your String is not empty). Here's the complete ammonite script for testing:

    import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._
    import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._
    
    import $ivy.`eu.timepit::refined:0.9.14`, eu.timepit.refined.types.string.NonEmptyString
    import $ivy.`io.circe::circe-refined:0.13.0`, io.circe.refined._
    
    final case class TestString(name: Option[NonEmptyString])
    
    implicit val customNonEmptyStringDecoder: Decoder[Option[NonEmptyString]] =
        Decoder[Option[String]].map(_.flatMap(NonEmptyString.unapply))
    
    val testString = TestString(NonEmptyString.unapply("test"))
    val emptyTestString = TestString(NonEmptyString.unapply(""))
    val noneTestString = TestString(None)
    val nullJson = """{"name":null}"""
    val emptyJson = """{}"""
    val emptyStringJson = """{"name":""}"""
    
    assert(testString.asJson.noSpaces == """{"name":"test"}""")
    assert(noneTestString.asJson.noSpaces == nullJson)
    assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)
    
    
    assert(decode[TestString](nullJson).exists(_ == noneTestString))
    assert(decode[TestString](emptyJson).exists(_ == noneTestString))
    assert(decode[TestString](emptyStringJson).exists(_ == noneTestString))