0

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)))
bwbecker
  • 1,031
  • 9
  • 21

2 Answers2

2

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
bwbecker
  • 1,031
  • 9
  • 21
Mateusz Kubuszok
  • 24,995
  • 4
  • 42
  • 64
0

Everything is working as explected when you are supplying the explicit type.

The NullPointerException here, is a runtime exception caused by the value supplied during the run time. Which means that the value supplied in the config is the culprit.

Your ConfigReader is failing at _.split(",") when the config contains a null string.

Just handle the null possibility in your code.

val csvIntListReader = 
  ConfigReader[String].map { s =>
    Option(s) match {
      case None => List.empty[Int]
      case Some(ss) => ss.split(",").map(_.toInt).toList
    }
  }

This will allow the conversion from null string to empty list. But this will still fail when the supplied list contains non-integer values.

Also, try to always use explicit types with implicit values, otherwise you will end up with a maze of inferred implicits to keep trace of.

Scala type inference works by backflowing the usage information to choose the type among the allowed possible types for the supplied value.

In case of implicit, the Scala complier wants to choose the best fit value among the available implicit values for the supplied type.

You should be able to see the problem now... when you leave the compiler to determine both the types of supplied values and values for the supplised types... the compiler is not going to be very happy about this.

sarveshseri
  • 13,738
  • 28
  • 47
  • Thank you, @sarveshseri, for your thoughtful reply and the time you put into it. I should have specified the run-time tests that failed. They were from the cited documentation and I've added them above. As you can see, there's no null string. Also, I'll just reiterate that this works when I leave out the explicit type. It fails when I try to do as you suggest and supply an explicit type. – bwbecker Jul 22 '23 at 20:25