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:
- The original one, cane be obtained from the core module at:
libraryDependencies += "com.github.pureconfig" %% "pureconfig-core" % "0.17.7"
- The new, more feature rich one can be obtained at:
libraryDependencies += "com.github.pureconfig" %% "pureconfig-generic-scala3" % "0.17.7"
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 ConfigReader
s 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!