Ciris v1.0.0 was announced less than a week ago, on October 28th, 2019. The release features a complete rewrite of the library, now reimagined and based upon Cats and Cats Effect. In this post, we'll highlight the most important changes in v1.0.0, and compare it with the v0.x versions.

Refer to the documentation for more detailed information on v1.0.0.

Why can't I just flatMap, parMapN, ...?

Ciris v0.x had custom functions loadConfig (roughly equivalent to parMapN) and withValues (roughly equivalent to parTupled.flatMap) for loading configurations. These were a consequence of having Scala Native support, a platform where Cats is still unavailable. Ciris v1.0.0 is not available on Scala Native, and depends on Cats while providing relevant type class instances.

As a result, code which used to look something like this in v0.x:

withValues(envF[IO, AppEnvironment]("APP_ENV")) { environment =>
  loadConfig(
    apiConfig(environment),
    databaseConfig
  ) { (api, database) =>
    Config(
      appName = "my-api",
      environment = environment,
      api = api,
      database = database
    )
  }
}

can now instead be written as follows in v1.0.0.

env("APP_ENV").as[AppEnvironment].flatMap {
  (
    apiConfig(environment),
    databaseConfig
  ).parMapN { (api, database) =>
    Config(
      appName = "my-api",
      environment = environment,
      api = api,
      database = database
    )
  }
}

Why env/envF, prop/propF, ...?

Ciris v0.x distinguished between pure and effectful configuration values. In Ciris v1.0.0, we require an effect type compatible with Cats Effect to load configurations. Loading is described using Cats Effect type classes, meaning you don't have to keep repeating the effect type for every envF, propF, and so on.

Additionally, there is now only one version per source: env, prop, and file. These no longer accept type arguments, and decoding of configuration values is done through as. Code which used to look something along the following lines in Ciris v0.x:

loadConfig(
  envF[IO, AppEnvironment]("APP_ENV"),
  envF[IO, Int]("MAX_RETRIES")
)(Config).orRaiseThrowable

can now instead be written as follows in v1.0.0.

(
  env("APP_ENV").as[AppEnvironment],
  env("MAX_RETRIES").as[Int]
).parMapN(Config).load[IO]

This allows configuration sources to focus on loading values, and not how to decode to different types.

Unifying and simplifying concepts

In Ciris v0.x, there were three similar concepts: ConfigEntry, ConfigValue, and ConfigResult. In Ciris v1.0.0, there is now only a single concept, ConfigValue, for describing configurations. This concept can be used to describe both horizontal composition (fallback values) and vertical composition (sequentially or parallelly).

Similarly to orElse, orNone, and orValue in v0.x, or can be used in v1.0.0 to use fallback values. Default values are now a first-class construct, meaning they can be reasoned about. In v0.x, we could not distinguish between default values and loaded values.

env("MAX_RETRIES").or(prop("max.retries")).as[Int].option

In the above example, .option is equivalent to .map(_.some).default(None), and .default(None) can also be written as .or(default(None)). Later defaults override earlier defaults, and this extends to defaults defined in arguments to or, and to composed configurations when using parallel composition (e.g. parMapN).

The ConfigError and ConfigErrors concepts in v0.x have also been combined into a single ConfigError concept, with support for both horizontal composition (through or) and vertical composition (parallelly, through and). Errors are mostly handled internally, and as users we most often only use ConfigValue.

Decoders are only for conversions

In Ciris v0.x, we used to say env[Option[Int]]("MAX_RETRIES") for optional values and Secret[Int] for secret values. This worked because ConfigDecoders could inspect errors of the value they were decoding, and create a new value based on that information.

In Ciris v1.0.0, ConfigDecoders can only access the value (and a description of the key, if available). So while it's possible to describe Option or Secret decoders, they won't get the right semantics — i.e. None as default value for Option, and sensitive details redacted from error messages for Secret.

Instead, we use functions like option and secret on ConfigValues to achieve the same result.

env("MAX_RETRIES").as[Int].option

env("API_KEY").or(prop("api.key")).secret

Configuration sources as functions

Ciris 0.x had the ConfigSource concept for describing how to load ConfigValues. In v1.0.0, this concept has been removed, and we can now create ConfigValues. Functions ConfigValue.loaded, ConfigValue.missing, ConfigValue.suspend, and similar functions enable us to describe configuration loading without ever talking about specific effect types.

Following is the definition of file, showing how to load file contents.

def file(path: Path, blocker: Blocker, charset: Charset): ConfigValue[String] =
  ConfigValue.blockOn(blocker) {
    ConfigValue.suspend {
      val key = ConfigKey.file(path, charset)

      try {
        val bytes = Files.readAllBytes(path)
        val value = new String(bytes, charset)
        ConfigValue.loaded(key, value)
      } catch {
        case _: NoSuchFileException =>
          ConfigValue.missing(key)
      }
    }
  }

Keys are actually descriptions

In Ciris 0.x, ConfigKeyType provided the name for keys, e.g. environment variable. Key and ConfigKeyType would then be passed throughout the configuration loading, in order to support sensible error messages. This has been simplified in v1.0.0 to ConfigKey, which fully describes the key, e.g. environment variable API_KEY.