Refined Configuration

There’s a great little Scala library, PureConfig, for avoiding boilerplate when loading configurations. I still see a lot of people using typesafe config and manually writing deserializers for each field – a tedious, error-prone process which rarely sees any testing efforts. PureConfig allows us to separate what to load from how it’s being loaded; that is, we can turn something like:

import com.typesafe.config._
// import com.typesafe.config._

case class Settings(config: Config) {
  val name = config.getString("name")

  object schedule {
    val initialDelaySeconds = config.getInt("schedule.initial-delay-seconds")
    val intervalMinutes = config.getInt("schedule.interval-minutes")
  }
}
// defined class Settings

val config = ConfigFactory.parseString(
  """
    |se.vlovgr.example {
    |  name = "My App"
    |  schedule {
    |    initial-delay-seconds = 10
    |    interval-minutes = 120
    |  }
    |}
  """.stripMargin
)
// config: com.typesafe.config.Config = Config(SimpleConfigObject({"se":{"vlovgr":{"example":{"name":"My App","schedule":{"initial-delay-seconds":10,"interval-minutes":120}}}}}))

val settings = Settings(config.getConfig("se.vlovgr.example"))
// settings: Settings = Settings(Config(SimpleConfigObject({"name":"My App","schedule":{"initial-delay-seconds":10,"interval-minutes":120}})))

settings.name
// res0: String = My App

settings.schedule.initialDelaySeconds
// res1: Int = 10

settings.schedule.intervalMinutes
// res2: Int = 120

into something like this:

import pureconfig.loadConfig
// import pureconfig.loadConfig

case class ScheduleSettings(initialDelaySeconds: Int, intervalMinutes: Int)
// defined class ScheduleSettings

case class Settings(name: String, schedule: ScheduleSettings)
// defined class Settings

loadConfig[Settings](config, "se.vlovgr.example")
// res3: Either[pureconfig.error.ConfigReaderFailures,Settings] = Right(Settings(My App,ScheduleSettings(10,120)))

which is a great improvement. We’ve cleanly separated the configuration from the way it’s being loaded. There’s no longer any reference to a Config, no more repetition of names, or tedious work in having to add new fields and defining how they should be loaded. As an added benefit, the configuration has been split into multiple classes, so that different parts of our application can cleanly depend on subsets of our configuration by requiring the appropriate settings type.

We can now simply add a new field with an appropriate type and be sure that PureConfig at compile-time generates what’s necessary to load our configuration. As you can see, loadConfig gives us an Either instance back, meaning it deals with possible errors during loading, which the first version simply ignored.

That’s all fine, but can we be sure that our configuration is valid just because we’ve been able to load it? No. For example, we want our application’s name to not be empty, and our schedule settings to be non-negative or positive. In the current version, we could break any of these implicit constraints, and not find out about it until later during run-time.

val invalidConfig = ConfigFactory.parseString(
  """
    |se.vlovgr.example {
    |  name = "My App"
    |  schedule {
    |    initial-delay-seconds = -10
    |    interval-minutes = 120
    |  }
    |}
  """.stripMargin
)
// invalidConfig: com.typesafe.config.Config = Config(SimpleConfigObject({"se":{"vlovgr":{"example":{"name":"My App","schedule":{"initial-delay-seconds":-10,"interval-minutes":120}}}}}))

// This works, but the settings are invalid
loadConfig[Settings](invalidConfig, "se.vlovgr.example")
// res5: Either[pureconfig.error.ConfigReaderFailures,Settings] = Right(Settings(My App,ScheduleSettings(-10,120)))

We could write validation logic to make sure these constraints hold, but that logic is tedious to write and needs testing. An arguably better way to do this is to record the constraints in the types of the configuration values. We could write these types ourselves, which require effort in writing and testing, but luckily, there’s already a great library, refined, for constraining types. Let’s see how our Settings look like with refined types.

import eu.timepit.refined.api.Refined
// import eu.timepit.refined.api.Refined

import eu.timepit.refined.collection._
// import eu.timepit.refined.collection._

import eu.timepit.refined.numeric._
// import eu.timepit.refined.numeric._

case class ScheduleSettings(
  initialDelaySeconds: Int Refined NonNegative,
  intervalMinutes: Int Refined Positive
)
// defined class ScheduleSettings

case class Settings(
  name: String Refined NonEmpty,
  schedule: ScheduleSettings
)
// defined class Settings

Thanks to refined, we’ve now clearly expressed our constraints in the types of the configuration values. It’s now practically impossible to create an instance of Settings which does not fulfill our constraints (disregarding null). Let’s see what happens when we try to load this configuration with PureConfig.

loadConfig[Settings](config, "se.vlovgr.example")
// <console>:27: error: could not find implicit value for parameter conv: pureconfig.ConfigConvert[Settings]
//        loadConfig[Settings](config, "se.vlovgr.example")
//                            ^

Looks like compilation failed because PureConfig doesn’t know how to load configuration values with Refined types. Luckily for you, I wrote a small integration between PureConfig and refined (#233) as part of my open source Christmas contributions. This allows you to get the above example to compile successfully, including configurations with any other Refined types. For this to work, add the optional refined-pureconfig library to your project’s dependencies:

libraryDependencies += "eu.timepit" %% "refined-pureconfig" % "0.8.0"

and simply add the appropriate import before loading the configuration.

import eu.timepit.refined.pureconfig._
// import eu.timepit.refined.pureconfig._

loadConfig[Settings](config, "se.vlovgr.example")
// res7: Either[pureconfig.error.ConfigReaderFailures,Settings] = Right(Settings(My App,ScheduleSettings(10,120)))

If we try to load the invalid configuration from before, we will now get an appropriate error message.

loadConfig[Settings](invalidConfig, "se.vlovgr.example")
// res8: Either[pureconfig.error.ConfigReaderFailures,Settings] = Left(ConfigReaderFailures(CannotConvert(-10,eu.timepit.refined.api.Refined[Int,eu.timepit.refined.boolean.Not[eu.timepit.refined.numeric.Less[shapeless.nat._0]]],Predicate (-10 < 0) did not fail.,None),List()))

We have now got a great way to load validated configurations without writing any boilerplate code.

If we want to create instances of Settings from code, we can do so as well, and at the same time get compile-time validation.

import eu.timepit.refined.auto._
// import eu.timepit.refined.auto._

// This works, and is validated at compile-time
Settings("My App", ScheduleSettings(10, 120))
// res10: Settings = Settings(My App,ScheduleSettings(10,120))
// This is validated and fails at compile-time
Settings("My App", ScheduleSettings(-10, 120))
// <console>:35: error: Predicate (-10 < 0) did not fail.
//        Settings("My App", ScheduleSettings(-10, 120))
//                                             ^