Search code examples
scalaimplicitscala-macrosplay-json

Generated play json implicit instances cause StackOverflowError


I have the following code:

import play.api.libs.json._
object Test {
   sealed trait T
   case class A(s: String) extends T

   implicit val writesA: OWrites[A] = Json.writes[A]
   implicit val writesT: OWrites[T] = Json.writes[T]

   def main(args: Array[String]): Unit = {
      val x = A("str")
      println(Json.toJson[T](x)(writesT))
   }
}

It causes a StackOverflowError when run.

If I add lazy in front of writesT, the StackOverflowError goes away and everything works:

import play.api.libs.json._
object Test {
   sealed trait T
   case class A(s: String) extends T

   implicit val writesA: OWrites[A] = Json.writes[A]
   implicit lazy val writesT: OWrites[T] = Json.writes[T]

   def main(args: Array[String]): Unit = {
      val x = A("str")
      println(Json.toJson[T](x)(writesT))
   }
}

The StackOverflowError also disappears when I move the implicits into the main function:

import play.api.libs.json._
object Test {
   sealed trait T
   case class A(s: String) extends T

   def main(args: Array[String]): Unit = {
      implicit val writesA: OWrites[A] = Json.writes[A]
      implicit val writesT: OWrites[T] = Json.writes[T]
      val x = A("str")
      println(Json.toJson[T](x)(writesT))
   }
}

Can anyone explain to me why I get a StackOverflowError in the first case?

My suspicion is that it has something to do with initialization orders and the macros that play-json uses in the background. But if that is the case I don't get why using lazy helps, because the code should still be generated at compile time and simply evaluating it later at runtime shouldn't change anything. Apparently in the later cases the writesA instance is found by writesT but not in the first case. Why does adding lazy resolve a compile-time issue with the resolution of implicits and macro code generation?

Or is this an issue on a completely different level?

I am using Scala 2.12.3 and play-json 2.6.2.


Solution

  • This is working just fine with Play JSON 2.7.x

    import play.api.libs.json._
    
    sealed trait T
    case class A(s: String) extends T
    
    implicit val writesA: OWrites[A] = Json.writes[A]
    implicit val writesT: OWrites[T] = Json.writes[T]
    
    val x = A("str")
    println(Json.toJson[T](x)(writesT))
    
    // Exiting paste mode, now interpreting.
    
    scala> println(Json.toJson[T](x)(writesT))
                                       ^
    {"s":"str","_type":"$line2.$read.$iw.$iw.A"}
    

    Guess it's related to the "blight of contravariant" on Writes implicit fixed since.

    Workaround for Play JSON 2.6 is to implement the Writes (or OFormat) instance for the sealed trait manually.

    scala> :paste
    // Entering paste mode (ctrl-D to finish)
    
    import play.api.libs.json._
    
    sealed trait T
    case class A(s: String) extends T
    
    implicit val writesT: OWrites[T] = {
      val writesA: OWrites[A] = Json.writes[A]
    
      OWrites[T] {
        case t: A => writesA.writes(t) + ("_type" -> Json.toJson("A"))
        case _ => ???
      }
    }
    
    val x = A("str")
    
    // Exiting paste mode, now interpreting.
    
    import play.api.libs.json._
    defined trait T
    defined class A
    writesT: play.api.libs.json.OWrites[T] = play.api.libs.json.OWrites$$anon$3@69a03da1
    x: A = A(str)
    
    scala> println(Json.toJson[T](x)(writesT))
    {"s":"str","_type":"A"}