Skip to content
GitHub

PureConfig with Scala 3 - Tutorial

It took some time, but the PureConfig on Scala 3 has almost reached the point of feature parity with its Scala 2 implementation. It’s a good moment to learn why it’s useful and how it works!

What’s PureConfig anyway?

PureConfig is a library that allows reading (and writing!) HOCON (a JSON superset) files. Using HOCON files is a nice way to have a light on syntax, language agnostic solution to externalize config in your applications. Over the time it became popular in some Scala / JVM apps. It allows you to have a common configuration approach used across apps and libs that can be merged together and have overrides. Mostly though, its main use case is the ENV variables loading, which works quite well. It could also be useful for reconfiguring your app without recompiling it, however - in the age of containers and strict CI/CD processes, this is drastically less useful.

As a side note. If you only care about config lib which handles env variables and you want to keep everything in Scala, you might want to check out Ciris!

How does it work?

PureConfig offers a number of type classes, which are: ConfigReader, ConfigWriter and ConfigConvert. These could be derived from your existing data models.

There is no need to parse the config yourself, or to do it imperatively through bare Lightbend / Typesafe config lib!

The derivation works quite differently, depending on which module and version of Scala you are using. Here we will focus on Scala 3 specifically.

Adding the dependency

Currently there are two implementations of Scala 3 derivation:

Note that, we are dependent on either pureconfig-core or pureconfig-generic-scala3, not just pureconfig. This last one, at the moment, is only for Scala 2.12 and 2.13.

pureconfig-core is a dependency of pureconfig-generic-scala3, so it’s fine to import only the latter one.

In this guide we will mostly focus on the pureconfig-generic-scala3, as it is the the most feature rich implementation, but as you will soon see, the pureconfig-core is still useful!

The pureconfig-generic-scala3 was something that I started working on last year, to help with cross-compiling a library at $WORK.

Deriving ConfigReader, ConfigWriter and ConfigConvert

Below are some scala-cli snippets. They have been checked with scala-cli --power ./blog-post.md, so all later examples assume the imports from the previous ones.

//> using dep com.github.pureconfig::pureconfig-generic-scala3:0.17.7
import pureconfig.*
import pureconfig.generic.semiauto.*

enum Pizza:
  case Pepperoni(tomatoes: Boolean = true)
  case Margherita(vege: Boolean)

given ConfigReader[Pizza] = deriveReader
given ConfigWriter[Pizza] = deriveWriter

// reading values
val pizza = ConfigSource.string("{ type: pepperoni }").load[Pizza]
println(pizza) // Right(Pepperoni(true))

// writing values
val written = ConfigWriter[Pizza].to(Pizza.Pepperoni(tomatoes = false))
println(written) // SimpleConfigObject({"tomatoes":false,"type":"pepperoni"})

If you always need both reading and writing, then you can also use the following:

given ConfigConvert[Pizza] = deriveConvert

The important thing to note here is that the derivation is recursive. If you define it for your top-level type, then all the sub-fields will also be derived (!).

The above feature can lead to compiler errors, which can be fixed by changing the value of -Xmax-inlines from 32 to something higher, or by deriving ConfigReaders field by field. See: https://github.com/pureconfig/pureconfig/issues/1676

Custom configuration

The implementation at pureconfig-generic-scala3 supports lazy default values and it’s fully configurable, it uses the same technique of implicit / given ProductHint and CoproductHint as the existing Scala 2 implementation, so porting old code should be much easier.

You can read more about supported configuration options in the Scala 2 guide here and here.

If something doesn’t work as expected please open an issue!

As the custom derivation for Scala 3 in the pureconfig-core got deprecated this is the recommended way to customize your derivation.

Tips and known limitations

Value classes derivation is not supported …yet

The value class derivation did not make it into the 0.17.7 release. Hopefully it will arrive later.

While in my opinion the value class derivation in PureConfig is a good default, you may prefer to define your own, custom instances. After all, the value classes are most likely your domain objects and may already have some validation rules!

No derives syntax

This is another thing that didn’t make it, but I hope it will be added at some point. The main idea of pureconfig-generic-scala3 was easy cross-compilation with Scala 2 code. This means that the implementation uses the implicit / given derivation hints in the same way as the old one. It was decided that with the derives syntax it might be even more confusing than usual, so the derives is out for now 😿.

That being said, if you know what you are doing, then you can easily define your own extension method for the derives syntax like this:

import pureconfig.generic.*
import scala.deriving.Mirror

extension (c: ConfigReader.type)
  inline def derived[A: Mirror.Of: ProductHint: CoproductHint]: ConfigReader[A] = deriveReader[A]
    
final case class Foo(bar: Int = 0) derives ConfigReader

val foo = ConfigSource.string("{}").load[Foo]
println(foo) // Right(Foo(0))

No support for Scala 3 specific types like |, & or opaque.

I haven’t had the time to look into this ⌛️.

No auto derivation

Personally, I’m not the biggest fan of the auto derivation in libraries like pureconfig or circe. It makes harder to spot what’s being derived and what’s not.

I haven’t thought about it too much, but I suspect that auto derivation could be done using the new scala.Conversion mechanism.

Enum derivation

Enum derivation in PureConfig is a little bit special, it’s only allowed for values where all possible instances are known beforehand. In this sense, the enum Foo { case Bar(value: Int )} is not an enum because, the Foo.Bar is parametrized.

Enums could be derived using semiauto methods from pureconfig-generic-scala3:

enum Tree:
  case Oak, Spruce

given ConfigConvert[Tree] = deriveEnumerationConvert

val tree = ConfigSource.string("{ value: oak }").at("value").load[Tree]
println(tree) // Right(Oak)

You can find them all here.

However, these methods are mostly just a facade for the Enum derivation in the pureconfig-core. The enum derivation does not depend on implicit hints and has derives syntax out of the box, so I decided not to touch the already existing EnumConfigReader, and extend the implementation with the EnumConfigWriter and EnumConfigConvert.

import pureconfig.generic.derivation.default.*
import pureconfig.generic.derivation.*

enum Fruit derives EnumConfigConvert:
  case Apple, Orange

val fruit = ConfigSource.string("{ value: orange }").at("value").load[Fruit]
println(fruit) // Right(Orange)

Importantly, the ability to derive custom instances of enums via traits is not deprecated. You can approach it as follows:

type MyEnumConfigReader[A] = MyEnumConfigReader.EnumConfigReader[A]
object MyEnumConfigReader extends EnumConfigReaderDerivation(ConfigFieldMapping(CamelCase, CamelCase))

enum Box derives MyEnumConfigReader:
  case Small, MediumLarge

val box = ConfigSource.string("{ value: MediumLarge }").at("value").load[Box]
println(fruit) // Right(MediumLarge)

However, you may find that semiauto derivation methods for enums are less boilerplate-ish though.

given ConfigConvert[Box] = deriveEnumerationConvert(ConfigFieldMapping(CamelCase, CamelCase))

Conclusion

While there are still some loose ends and navigating through the Scala 3 derivation in PureConfig can be confusing, the overall situation is much better than it was just a few months ago.

Hopefully, this will make the migration to Scala 3 possible for many more people!