Search code examples
scalapureconfig

explicit types for implicit pureconfig ConfigReaders


Scala v2.13.11 issues a warning for the following code from the pureconfig orElse documentation (bottom of the page):

val csvIntListReader = ConfigReader[String].map(_.split(",").map(_.toInt).toList)
implicit val intListReader = ConfigReader[List[Int]].orElse(csvIntListReader)

case class IntListConf(list: List[Int])

The warning is:

[warn] Implicit definition should have explicit type (inferred pureconfig.ConfigReader[List[Int]])
[warn]   implicit val intListReader = ConfigReader[List[Int]].orElse(csvIntListReader)
[warn]                ^

When I add the inferred type, ConfigReader[List[Int]], it compiles without the warning but I get a NullPointerException at run-time.

This raises the following questions for me:

  1. Why does this work when we let the compiler infer the type but it does not when we explicitly supply the type the compiler says it inferred?
  2. Is there a type that can be explicitly given to intListReader that will compile without a warning and run without error?
  3. If 3 is not possible, is adding @nowarn "safe" (eg still work with Scala3)?

Thanks for your insights.

PS: My run-time tests are also from the documentation:

ConfigSource.string("""{ list = [1,2,3] }""").load[IntListConf] ==> Right(IntListConf(List(1, 2, 3)))

and

ConfigSource.string("""{ list = "4,5,6" }""").load[IntListConf] ==> Right(IntListConf(List(4, 5, 6)))

Solution

  • It's an undocumented undefined behavior.

    When you do:

    implicit val x: X = implicitly[X]
    

    compiler would generate

    implicit val x: X = x
    

    Which depending on context (where you have out it, is it val, lazy val or def) will end up with:

    • compilation error (as you saw)
    • NullPointerException or InitializationError
    • StackOverflowException

    Usually, you would like to have it resolved to:

    // someCodeGenerator shouldn't use x
    implicit val x: X = implicitly[X](someCodeGenerator)
    

    which would use some mechanics to avoid using x in the process of computing the implicit. E.g. by using some wrapper or subtype to unwrap/upcast (e.g. in Circe you ask for Encoder/Decoder derived with semiauto and what is obtained by implicit is some DerivedEncoder/DerivedDecoder to unwrap/upcast).

    This is defined behavior coming from how implicits are resolved when all of implicit definitions in your scope are annotated.

    What happens if some is not like this one?

    • compiler is asked about implicit ConfigReader[List[Int]] while computing intListReader
    • in the implicit scope there is no value explicitly annotated like this
    • there is one which type has to be inferred (intListReader)
    • the behavior here is undefined and undocumented, but compiler authors decided to skip this implicit and continue
    • value is computed without this implicit
    • type of the value (intListReader) is computed to be ConfigReader[List[Int]]
    • code below that definition sees that implicit with this type

    In general, library shouldn't rely on this behavior, there should be some semiautomatic derivation provided for you and in future versions on Scala (3.x) this code is illegal so I suggest rewriting it to semiauto.

    In your particular case you can also use a trick, to summon different implicit of a different than the one being returned:

    implicit val intListReader: ConfigReader[List[Int]] =
      ConfigReader[Vector[Int]].map(_.toList) // avoids self-summoning
        .orElse(
          ConfigReader[String].map(_.split(",").map(_.toInt).toList)
        )
    

    but if it wasn't a type with a build-in support (they don't work with deriveReader as it is only defined for sealed and case class) you could use:

    import pureconfig.generic.semiauto._
    
    implicit val intListConf: ConfigReader[IntListConf] =
      deriveReader[IntListConf] // NOT refering intListConf