Error Handling

PureConfig features a rich error model used on reading operations. Most PureConfig methods that read Scala types from configurations return a ConfigReader.Result[A] - an alias for Either[ConfigReaderFailures, A], with A being the type of a successful result and ConfigReaderFailures being a non-empty list of errors that caused the reading operation to fail.

From the various types of ConfigReaderFailure, one of them is of particular interest: a ConvertFailure is an error occurred during the conversion process itself. It features a reason (FailureReason), an optional location in the config files where the conversion error occurred and a path in the config.

There are several possible FailureReasons, the most common being:

  • A general, uncategorized reason (CannotConvert);
  • A required key was not found (KeyNotFound);
  • A config value has a wrong type (WrongType).

For example, given a config like this:

import com.typesafe.config.ConfigFactory
import pureconfig._
import pureconfig.generic.auto._

case class Name(firstName: String, lastName: String)
case class Person(name: Name, age: Int)
case class Conf(person: Person)

Trying to load it with a string instead of an object at name results in a ConvertFailure because of a WrongType:

val res = ConfigSource.string("{ person: { name: John Doe, age: 35 } }").load[Conf]
// res: pureconfig.ConfigReader.Result[Conf] = Left(ConfigReaderFailures(ConvertFailure(WrongType(STRING,Set(OBJECT)),None,person.name),List()))

All error-related classes are present in the pureconfig.error package.

Validations in custom readers

When implementing custom readers, the cursor API already deals with the most common reasons for a reader to fail. However, it also provides a failed method for users to do validations on their side, too:

import com.typesafe.config.ConfigValueType._
import scala.util.{Try, Success, Failure}
import pureconfig.error._

case class PositiveInt(value: Int) {
  require(value >= 0)
}

implicit val positiveIntReader = ConfigReader.fromCursor[PositiveInt] { cur =>
  cur.asString.flatMap { str =>
    Try(str.toInt) match {
      case Success(n) if n >= 0 => Right(PositiveInt(n))
      case Success(n) => cur.failed(CannotConvert(str, "PositiveInt", s"$n is not positive"))
      case Failure(_) => cur.failed(WrongType(STRING, Set(NUMBER)))
    }
  }
}

case class Conf(n: PositiveInt)
ConfigSource.string("{ n: 23 }").load[Conf]
// res0: pureconfig.ConfigReader.Result[Conf] = Right(Conf(PositiveInt(23)))

ConfigSource.string("{ n: -23 }").load[Conf]
// res1: pureconfig.ConfigReader.Result[Conf] = Left(ConfigReaderFailures(ConvertFailure(CannotConvert(-23,PositiveInt,-23 is not positive),None,n),List()))

ConfigSource.string("{ n: abc }").load[Conf]
// res2: pureconfig.ConfigReader.Result[Conf] = Left(ConfigReaderFailures(ConvertFailure(WrongType(STRING,Set(NUMBER)),None,n),List()))

Custom failure reasons

Users are not restricted to the failure reasons provided by PureConfig. If we wanted to use a domain-specific failure reason for our PositiveInt, for example, we could create it like this:

case class NonPositiveInt(value: Int) extends FailureReason {
  def description = s"$value is not positive"
}

implicit val positiveIntReader = ConfigReader.fromCursor[PositiveInt] { cur =>
  cur.asString.flatMap { str =>
    Try(str.toInt) match {
      case Success(n) if n >= 0 => Right(PositiveInt(n))
      case Success(n) => cur.failed(NonPositiveInt(n))
      case Failure(_) => cur.failed(WrongType(STRING, Set(NUMBER)))
    }
  }
}
ConfigSource.string("{ n: -23 }").load[Conf]
// res3: pureconfig.ConfigReader.Result[Conf] = Left(ConfigReaderFailures(ConvertFailure(NonPositiveInt(-23),None,n),List()))

Throwing an exception instead of returning Either

In some usage patterns, there isn’t a need to deal with errors as values. For example, a good practice to handle configs in an application is to load the whole config with PureConfig at initialization time, causing the application to fail fast in case of a malformed config. For those cases, the loadOrThrow method can be used instead of load:

ConfigSource.string("{ n: 23 }").loadOrThrow[Conf]
// res4: Conf = Conf(PositiveInt(23))
ConfigSource.string("{ n: -23 }").loadOrThrow[Conf]
// pureconfig.error.ConfigReaderException: Cannot convert configuration to a Conf. Failures are:
//   at 'n':
//     - -23 is not positive
// 
//   at pureconfig.ConfigSource.loadOrThrow(ConfigSource.scala:80)
//   at pureconfig.ConfigSource.loadOrThrow$(ConfigSource.scala:77)
//   at pureconfig.ConfigObjectSource.loadOrThrow(ConfigSource.scala:91)
//   ... 43 elided

The message of the thrown exception contains human-readable information of all the errors found by PureConfig, with the errors grouped and organized by path.