Strongly Typed Configuration Access With Code Generation

Most config libraries use a stringly typed approach.

Some handle runtime failures due to invalid configuration schemas by leveraging data types like Option or Result to represent missing values or errors. This allows us to handle these failures by either providing default values or by providing decent error messages.

This is a good strategy that we should definitely stick to.

However, the problem with default values is that we might not even notice if the configuration is broken. This could potentially fail in production. In any case an error e.g. due to a misspelled config property will be observable at runtime at the earliest.

Wouldn’t it be a great user experience (for us developers) if the compiler told us if the configuration schema is invalid? Even better, imagine we could access the configuration data in a strongly typed way like any other data structure, and with autocompletion.

Moreover, what if we didn’t have to write any glue code, not even when the configuration schema changes?

This can be done with the costs of an initial setup that won’t take more than probably around 5 minutes.

The process and the tooling that I will describe might not be perfect, yet. But let’s first see how it goes and evaluate later.

Initial setup

We assume that this is the path to the configuration file: src/main/resources/application.conf.

Typesafe Config for sbt

Make Typesafe Config available to sbt by adding the file project/build.sbt:

libraryDependencies += "com.typesafe" % "config" % "1.3.1"

Typesafe Config and Play JSON for project

Add the Typesafe Config and the Play JSON library to the build.sbt in the root folder:

lazy val root = (project in file("."))
  .settings(
    // ...
    libraryDependencies += "com.typesafe.play" %% "play-json" % "2.6.7",
    libraryDependencies += "com.typesafe" % "config" % "1.3.1"
  )

sbt task to generate a JSON config

Now we will add a custom sbt task generateConfigJsonTask to generate a JSON file from the application.conf file during compilation:

import com.typesafe.config.{ConfigFactory, ConfigRenderOptions}

lazy val root = (project in file("."))
  .settings(
    // ...
    generateConfigJson,
    (compile in Compile) := (compile in Compile)
      .dependsOn(generateConfigJsonTask)
      .value
  )

val generateConfigJsonTask =
  TaskKey[Unit]("generateConfigJson", "Generate JSON config sample.")

val generateConfigJson = generateConfigJsonTask := {
  val config = ConfigFactory.parseFile(
    (baseDirectory in Compile).value / "src" / "main" / "resources" / "application.conf")
  val content = config.root().render(ConfigRenderOptions.concise())
  val file = (baseDirectory in Compile).value / "src" / "main" / "resources" / "json" / "configuration.json"
  IO.write(file, content)
}

After running reload and compile in sbt the application.conf should be rendered to JSON format stored under src/main/resources/json/configuration.json.

Add sbt-json plugin

Provide the sbt-json Plugin by adding or editing the file project/plugins.sbt:

addSbtPlugin("com.github.battermann" % "sbt-json" % "0.4.0")

Edit the build.sbt file to enable the plugin:

lazy val root = (project in file("."))
  .enablePlugins(SbtJsonPlugin)
  .settings(
    // ...
  )

The plugin uses the generated JSON version of the config to generate case classes that model the config JSON object and are available at compile time. By default all JSON files under src/main/resources/json will be processed.

Add trait Config

Add the trait Config within the application:

import com.typesafe.config.{ConfigFactory, ConfigRenderOptions}
import jsonmodels.configuration.Configuration
import play.api.libs.json.Json

trait Config {
  private val config = ConfigFactory.load()
  private val configJsonString = config.root().render(ConfigRenderOptions.concise())
  val configuration: Configuration = Json.parse(configJsonString).as[Configuration]
}

First the config is loaded from the ConfigFactory. Then it is rendered to a JSON string. Finally the JSON string is parsed with Play JSON and converted to an instance of Configuration which is an immutable, strongly typed value that can be used to access config data.

Using the config

This is all we have to do and we only have to do this once. If we change the application.conf file the Configuration model will reflect that change immediately at compile time. The compiler will tell us all the places in the application that use the configuration and that have to be updated accordingly.

object Main extends App with Config {
  println(configuration.team.member.service.url)
}
// http://team:6768/api/teams?member=

We can access config data with the help of autocompletion. For the sake of demonstration I used a config from here:

alt text

Caveats

There are two obvious downsides to this approach. Type-wise we are limited to the HOCON subset that can be represented by JSON. And we cannot use the complete feature set that Typesafe Config offers, e.g. substitutions will not work.

Conclusion

We saw how we can access config data in a strongly typed way.

Changes to the configuration schema will reflect immediately at compile time.

We only have to set it up once and for all. There is no need to write and update the configuration model manually which can be tedious. (Note that there are some libraries that take the latter approach and specifically favor it over code generation).

The idea is not new. E.g. F#’s type providers are based on it, as well.

Even though the workflow and tooling might not be perfect, yet (I consider the sbt-json plugin to be in experimental state), I think it greatly improves developer experience. I am definitely going to explore this more in the future. If we had well tested, full featured, and reliable tools with even better user experience, this would be my default way of configuration access. Let me know what you think!

Complete source code on GitHub