Tips and tricks for PureConfig on Scala 3

(, en)

For most projects PureConfig is a good choice. It gives you the ability to read your (Typesafe) config into a case class. PureConfig already supports Scala 3, The code below is tested on Scala 3.6.3 hence it is my first choice when it comes to dealing with configuration in Scala projects.

Unfortunately it is not always clear how to deal with special edge cases, so here a short overview of things I run into frequently:

Which libraries do I need

libraryDependencies ++= Seq(
  "com.github.pureconfig" %% "pureconfig-core" % "0.17.8",
  // this one you need for defaults in case classes
  "com.github.pureconfig" %% "pureconfig-generic-scala3" % "0.17.8",
),

How can I read fields that are lists and strings into List[String]

At some point you probably want to deploy your app.

Let’s assume you have a Kafka consumer that needs to consume one or more topics.

topics: [ "address-changes" ]
topics: ${?CONSUMER_TOPICS}

You would like to set this via an environment variable so that in your test environment the consumer consumes topics where test data can be injected:

CONSUMER_TOPICS=address-changes,address-changes-injected

PureConfig can do this, but you need to supply your own ConfigReader. My first try looked something like this:

def splitDelim(v: String, delim: String = ","): Seq[String] = v.split(delim).map(_.trim).toSeq

given listReader: ConfigReader[List[String]] = ConfigReader
  .fromCursor(c =>
    c.asConfigValue.map(_.valueType()) match {
      case Right(ConfigValueType.STRING) => c.asString.map(splitDelim(_).toList)
      case _                             => c.asList.flatMap(_.map(_.asString).sequence.map(_.toList))
    }
  )

You can then supply either a list or a string:

import ConfigUtil.listReader

it should "parse a list" in {
  case class Conf(a: List[String] = List("d", "e", "f")) derives ConfigReader
  val config = ConfigSource.string("""a = ["u", "v", "w"]""").load[Conf]
  config should be(Right(Conf(List("u", "v", "w"))))
}

it should "parse a string" in {
  case class Conf(a: List[String] = List("d", "e", "f")) derives ConfigReader
  val config = ConfigSource.string("""a = "u,v,w"""").load[Conf]
  config should be(Right(Conf(List("u", "v", "w"))))
}

After some fiddling I was able to shorten it to

given listReader: ConfigReader[List[String]] =
  ConfigReader[List[String]].orElse(ConfigReader.fromString(catchReadError(splitDelim(_).toList)))

Defaults from case classes

Using case class Conf() derives ConfigReader works in most cases, but as soon as you want to have a default value in your case class, you have to pull in pureconfig-generic-scala3 as extra dependency (see also issue on GitHub):

libraryDependencies ++= Seq(
  "com.github.pureconfig" %% "pureconfig-core" % "0.17.8",
  // this one you need for defaults in case classes
  "com.github.pureconfig" %% "pureconfig-generic-scala3" % "0.17.8",
),

This extra dependency gives you deriveReader

import pureconfig.generic.semiauto.deriveReader

deriveReader supports default values:

it should "use the case class default" in {
  case class Conf(a: List[String] = List("d", "e", "f"))
  // we use deriveReader instead of `derives ConfigReader`
  // to get support for default values in case classes
  given ConfigReader[Conf] = deriveReader[Conf]

  val config = ConfigSource.string("").load[Conf]
  config should be(Right(Conf(List("d", "e", "f"))))
}