Supporting New Types

Not all types are supported automatically by PureConfig. For instance, classes that are not case classes are not supported out-of-the-box:

import pureconfig._
import pureconfig.generic.auto._

class MyInt(value: Int) {
  override def toString: String = s"MyInt($value)"
}

case class Conf(n: MyInt)

In order to read an instance of a given type A from a config, PureConfig needs to have in scope in implicit instance of ConfigReader[A]. This won’t compile because there’s no ConfigReader instance for MyInt:

ConfigSource.string("{ n: 1 }").load[Conf]
// error: could not find implicit value for parameter reader: pureconfig.ConfigReader[repl.MdocSession.MdocApp.Conf]
// ConfigSource.string("{ n: 1 }").load[Conf]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

PureConfig can be extended to support those types. To do so, an instance for the ConfigReader type class must be provided. There are three main ways to build such an instance:

  • Modify an existing instance for another type by using one of the ConfigReader combinators;
  • Use one of the ConfigReader convenience factory methods;
  • Create a new implementation of the ConfigReader interface from scratch.

For the MyInt type above, we could create a ConfigReader[MyInt] by mapping the result of ConfigReader[Int] like this:

implicit val myIntReader = ConfigReader[Int].map(n => new MyInt(n))

Note that the ConfigReader[Int] expression “summons” an existing implicit instance, being syntactic sugar for implicitly[ConfigReader[Int]]. This is usually the easiest way to create a ConfigReader for simple types. See Combinators for more examples.

As an example for the second approach, we could read the required integer by parsing it from a string form like this:

implicit val myIntReader = ConfigReader.fromString[MyInt](
  ConvertHelpers.catchReadError(s => new MyInt(s.toInt)))

The fromString factory method allows users to easily read data from string representations in the config. catchReadError is a convenience function that catches exceptions thrown by the parsing code and transforms them into PureConfig errors.

Finally, we could simply implement the ConfigReader interface by hand:

implicit val myIntReader = new ConfigReader[MyInt] {
  def from(cur: ConfigCursor) = cur.asString.map(s => new MyInt(s.toInt))
}

The interface consists of a single from method that takes a ConfigCursor and returns an Either of a MyInt or a list of errors. You can read more about cursors at Config Cursors.

Using any of the approaches above would now make the config be loaded successfully:

ConfigSource.string("{ n: 1 }").load[Conf]
// res1: ConfigReader.Result[Conf] = Right(Conf(MyInt(1)))

The case above serves as an example for most simple types. While for those types it is straightforward to create a ConfigReader, complex types that require access to an entire sub-tree of the configuration to be read can make implementing an appropriate ConfigReader non-trivial. The Complex Types section presents different approaches for doing that, along with their advantages and disadvantages.