scalareflectionscala-3

How to get the value of a field of a case class by reflection in Scala 3


I'm migrating code that used to use Scala 2 reflection.

I'm trying to use scala-reflection library.

I need to get the value of a field of a given name of a case class.

Example:

$ scala-cli -j system -S 3.4.2 --dep co.blocke::scala-reflection:2.0.8
scala> import co.blocke.scala_reflection.RType

scala> case class A(a: Int, b: String)
// defined case class A

scala> import co.blocke.scala_reflection.rtypes.ScalaClassRType

scala> val t = RType.of[A].asInstanceOf[ScalaClassRType[A]]
val t: co.blocke.scala_reflection.rtypes.ScalaClassRType[A] = ScalaClassRType(rs$line$2$A,rs$line$2$.A,List(),List(),List(),List(ScalaFieldInfo(0,a,IntRType(),Map(),None,None,false), ScalaFieldInfo(1,b,StringRType(),Map(),None,None,false)),Map(),List(java.lang.Object, scala.Product, java.io.Serializable),false,false,true,false,Map(),List(),List(),false)

scala> t.fields.find(_.name == "a")
val res2: Option[co.blocke.scala_reflection.rtypes.FieldInfo] = Some(ScalaFieldInfo(0,a,IntRType(),Map(),None,None,false))

scala> val c = A(3,"hola")
val c: A = A(3,hola)

In this example, how can I get the value of field a by reflection on instance c.

If it is easier, I could consider using other libraries like izumi-reflect.


Solution

  • Assuming you want to serialize things by putting them into JDBC one field after another:

    trait SerializationTarget
    object SerializationTarget {
      val example: SerializationTarget = new SerializationTarget {}
    }
    

    this is a stub I will use as something you'd be passing around to call some API.

    trait SerializeForJDBC[A] {
    
      def serialize(value: A, target: SerializationTarget): Unit
    }
    

    This will be the type class which would take the value of A and somehow put all its data into SerializationTarget - however that's going to look like.

    We'll start with some (stub) instances showing how primitives could be handled:

    object SerializeForJDBC {
    
      given SerializeForJDBC[Int] with
        def serialize(value: Int, target: SerializationTarget): Unit = ()
      given SerializeForJDBC[Double] with
        def serialize(value: Double, target: SerializationTarget): Unit = ()
      given SerializeForJDBC[String] with
        def serialize(value: String, target: SerializationTarget): Unit = ()
    }
    
    extension [A](value: A)
      final def serializeForJDBC(target: SerializationTarget)(using
          SerializeForJDBC[A]
      ): Unit =
        summon[SerializeForJDBC[A]].serialize(value, target)
    

    Now, when we would call

    10.serializeForJDBC(SerializationTarget.example)
    20.0.serializeForJDBC(SerializationTarget.example)
    "test".serializeForJDBC(SerializationTarget.example)
    

    code would pick up the right given and use it, basically like a strategy pattern, but the strategy is DI-ed based on a type.

    Finally, we would use Mirrors to generate such strategy for every case class:

    object SerializeForJDBC {
      // other givens ...
    
      import scala.compiletime.*
      import scala.deriving.*
    
      inline given derived[A <: Product](using
          p: Mirror.ProductOf[A]
      ): SerializeForJDBC[A] = {
        new SerializeForJDBC[A] {
          // - p.MirroredElemTypes is a path-dependent type, which is a tuple
          //   containing types for the fields of a case class in order in which
          //   they were defined
          // - Tuple.Map turns it into (SerializeForJDBC[Field1], SerializeForJDBC[Field2], ...)
          // - summonAll takes the type of a tuple and returns a tuple of givens
          // - that's why we had to define givens before and put them into scope
          //   e.g. by putting them into the companion object of a type class
          private val instances =
            summonAll[Tuple.Map[p.MirroredElemTypes, SerializeForJDBC]].toList
              .asInstanceOf[List[SerializeForJDBC[Any]]]
    
          def serialize(value: A, target: SerializationTarget): Unit =
            // the easiest way of passing values into mirrors is to cast everything
            // as Any and TypeClass[Any] and make sure that indices are correct
            // e.g. using .zip
            value.productIterator.zip(instances).foreach { case (field, instance) =>
              instance.asInstanceOf[SerializeForJDBC[Any]].serialize(field, target)
            }
        }
      }
    }
    

    Finally, we can call it:

    case class Example(a: Int, b: Double, c: String)
    
    Example(1, 2.0, "3").serializeForJDBC(SerializationTarget.example)
    

    It would create a new instance of SerializeForJDBC[Example] every time it is needed. It shouldn't be much of an issue, but if we want to cache the instance, we can make use of derives keyword (since we defined the mirror-based method as derived):

    case class Example(a: Int, b: Double, c: String) derives SerializeForJDBC
    
    Example(1, 2.0, "3").serializeForJDBC(SerializationTarget.example)
    

    In your actual code you'd have to replace SerializationTarget with however you interact with JDBC, replace the bodies of primitive givens and adjust how you'd combine instances for each field into an instance of a whole case class.

    An alternative to build-in Mirrors is Magnolia library - since it's macro-based it's able to provide slightly better error messages than missing implicits/givens. It's also a good choice if one want to have a cross-compiled solution between Scala 2 and Scala 3.