Jurate is a simple Scala 3 library for instantiating case class instances from environment variables and system properties using compile-time derivation. You just need to create a case class with the desired fields and annotate them with @env or @prop. Then you can load your config using load method.
import jurate.{*, given}
case class DbConfig(
@env("DB_PASSWORD") password: Secret[String],
@env("DB_USERNAME") username: String
)
case class Config(
@env("HOST") host: String,
@env("PORT") port: Int = 8080,
@env("ADMIN_EMAIL") adminEmail: Option[String],
@prop("app.debug") debug: Boolean = false,
dbConfig: DbConfig
)
println(load[Config])
// Right(Config(localhost,8080,None,false,DbConfig(Secret(d74ff0ee8d),db_reader)))Requirements: Scala 3.3+
Add to your build.sbt:
libraryDependencies += "io.github.katlasik" %% "jurate" % "0.3.0"You have to import givens using:
import jurate.{*, given}This provides instance of ConfigReader which is required to load values from environment or system properties.
To load a value into a field from an environment variable, use the @env annotation. To load a value from a system property, use the @prop annotation.
You can provide multiple annotations to a field. The library will try to load the value from the first annotation on the left, and if it fails, it will try the next one.
You can also provide a default value for a field, which will be used if the value is not found for any of the annotations.
case class EmailConfig(
@prop("debug.email") @env("EMAIL") @env("ADMIN_EMAIL") email: String = "foo@bar.com"
)In this example library will first check if system property debug.email exists, then it will look for environment variables EMAIL and ADMIN_EMAIL. If none are found default value foo@bar.com will be used.
You can make field optional by using Option type. If the value is not found, the field will be set to None.
case class AdminEmailConfig(
@env("ADMIN_EMAIL") adminEmail: Option[String],
)You can use nested case classes to organize your config.
case class DatabaseConfig(
@env("DB_PASSWORD") password: Secret[String],
@env("DB_USERNAME") username: String
)
case class AppConfig(
@env("HOST") host: String,
@env("PORT") port: Int = 8080,
dbConfig: DatabaseConfig
)You can load values of singleton enums (with no fields) using @env or @prop annotations. The library will automatically convert the loaded value to the enum case. Searching for the right enum case is case-sensitive.
enum Environment:
case DEV, PROD, STAGING
case class EnvConfig(
@env("ENV") env: Environment
)If you want to customize loading of enum you can provide your own instance of ConfigDecoder:
given ConfigDecoder[Environment] = new ConfigDecoder[Environment]:
def decode(raw: String): Either[String, Environment] =
val rawLowercased = raw.trim().toLowerCase()
Environment
.values
.find(_.toString().toLowerCase() == rawLowercased)
.toRight(s"Couldn't find right value for Environment: $raw")The result of loading sealed trait will be first subclass to load successfully.
sealed trait MessagingConfig
case class LiveConfig(@env("BROKER_ADDRESS") brokerAddress: String) extends MessagingConfig
case class TestConfig(@prop("BROKER_NAME" ) brokerName: String) extends MessagingConfig
case class Config(messaging: MessagingConfig)The same works for enums with fields
enum MessagingConfig:
case LiveConfig(@env("BROKER_ADDRESS") brokerAddress: String)
case TestConfig(@prop("BROKER_NAME" ) brokerName: String)
case class Config(messaging: MessagingConfig)If don't want to expose your secret values in logs or error messages, you can use Secret type. It displays a short SHA-256 hash instead of the actual value when printed.
case class DbCredsConfig(
@env("DB_PASSWORD") password: Secret[String],
@env("DB_USERNAME") username: String
)
println(load[DbCredsConfig])
// Right(DbCredsConfig(Secret(d74ff0ee8d),db_reader))The Secret type shows only the first 10 characters of the SHA-256 hash, which helps with debugging while keeping sensitive data protected.
You can load comma-separated values into collections. Currently, it's not possible to change separator.
case class Numbers(@env("NUMBERS") numbers: List[Int])
println(load[Numbers])
// Right(Numbers(List(1, 2, 3)))With environment variable containing "1,2,3" the result will contain Right(Numbers(List(1,2,3))).
Library provides built-in decoders for many common types:
Primitive Types:
StringInt,Long,Short,ByteDouble,FloatBooleanChar
Standard Library Types:
BigInt,BigDecimalUUIDURIPath(java.nio.file.Path)File(java.io.File)FiniteDuration(scala.concurrent.duration.FiniteDuration),Duration(scala.concurrent.duration.Duration)List[T],Seq[T],Vector[T]Option[T]- returnsNoneif value not found
You can add custom decoders for your types by implementing ConfigDecoder typeclass:
class MyClass(val value: String)
given ConfigDecoder[MyClass] with {
def decode(raw: String): Either[String, MyClass] = {
if (raw.isEmpty)
Left("Value is empty")
else
Right(new MyClass(raw))
}
}You can override behavior of load function by providing instance of ConfigReader.
For test, you can use mocked ConfigReader:
case class DbConf(@env("DATABASE_HOST") host: String, @prop("dbpass") password: String)
given ConfigReader = ConfigReader
.mocked
.onEnv("DATABASE_HOST", "localhost")
.onProp("dbpass", "mypass")
println(load[DbConf])
// Right(DbConf(localhost,mypass))Configuration loading returns an Either[ConfigError, Config]. When errors occur, you can format them for display using different printers.
By default, errors use getMessage which provides a text-based error list:
case class AppConfig(
@env("PORT") port: Int,
@env("HOST") host: String
)
load[AppConfig] match {
case Left(error) =>
println(error.getMessage)
// Configuration loading failed with following issues:
// Missing environment variable PORT
// Missing environment variable HOST
case Right(config) => // ...
}For better readability, use TablePrinter to display errors in a formatted table:
import jurate.printers.TablePrinter
load[Config] match {
case Left(error) =>
System.err.println(error.print(using TablePrinter))
// ┌───────┬────────────┬─────────────────────────────┐
// │ Field │ Source │ Message │
// ├───────┼────────────┼─────────────────────────────┤
// │ port │ PORT (env) │ Missing configuration value │
// ├───────┼────────────┼─────────────────────────────┤
// │ host │ HOST (env) │ Missing configuration value │
// └───────┴────────────┴─────────────────────────────┘
case Right(config) => // ...
}You can create custom error formatters by implementing the ErrorPrinter trait:
import jurate.ErrorPrinter
object CompactPrinter extends ErrorPrinter {
def format(error: ConfigError): String =
error.reasons.map {
case Missing(field, _) => s"Missing: $field"
case Invalid(_, detail, _, _) => s"Invalid: $detail"
case Other(field, detail, _) => s"Error: $detail"
}.mkString(" | ")
}
error.print(using CompactPrinter)
// Missing: port | Missing: hostYou can find more examples under src/examples.
You can run them using sbt "examples/runMain <example-class>" command (set necessary environment variables first). For instance:
sbt "examples/runMain jurate.simpleApp"