Skip to Content
Course content

48: Practice Exercise: Building a Type-Safe Configuration Loader

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've spent a lot of time talking about Either and Option in previous lessons, but the real magic happens when you use them to shield your application's core logic from the "messiness" of the outside world. A classic example is configuration. Most config files are just strings, but your business logic needs integers, booleans, and durations. If you just call .toInt on a config string and it's missing or malformed, your app crashes at startup with a stack trace that's a mile long.

I want to show you how to build a loader that transforms a raw map of strings into a type-safe case class, ensuring that if anything is wrong, we get a clean list of errors instead of a crash.

Defining the Target Shape

First, let's define what we actually want. I'm building a simple database configuration. I don't want to pass a Map around my whole app; I want a dedicated case class. It's cleaner and makes the code self-documenting.

case class DbConfig(
  host: String,
  port: Int,
  timeoutMs: Int
)

Now, I need a way to represent what went wrong. A simple String for an error is usually enough for small projects, but I prefer a sealed trait so I can handle different error types programmatically if this grows.

sealed trait ConfigError
case class MissingKey(key: String) extends ConfigError
case class InvalidValue(key: String, value: String) extends ConfigError

Trying the "Quick and Dirty" Way

When I first started writing this, I tried to keep it simple. I wrote a helper to grab a value and just used .toInt for the numbers. It looked like this:

def loadConfig(raw: Map[String, String]): DbConfig = {
  DbConfig(
    host = raw("db.host"),
    port = raw("db.port").toInt,
    timeoutMs = raw("db.timeout").toInt
  )
}

I quickly realized this is a disaster. If db.host is missing, I get a NoSuchElementException. If db.port is "eighty-eighty", I get a NumberFormatException. It's exactly the "crash at startup" scenario I wanted to avoid. I'm essentially just pushing the errors to the runtime.

Making the Extraction Type-Safe

To fix this, I need a helper method that returns an Either. Instead of throwing exceptions, it should return a Left(ConfigError). I'll write a generic helper that can handle the lookup and an optional transformation function (like toInt).

def getValue[T](raw: Map[String, String], key: String)(transform: String => T): Either[ConfigError, T] = {
  raw.get(key) match {
    case None => Left(MissingKey(key))
    case Some(value) => 
      try {
        Right(transform(value))
      } catch {
        case _: Exception => Left(InvalidValue(key, value))
      }
  }
}

I used a try-catch block here. While some Scala purists might prefer Try().toEither, in a configuration loader, this is a pragmatic way to wrap the standard library's throwing converters.

Wiring it Together

Now I can assemble the DbConfig. Since I'm using Either, I can use a for-comprehension. This is great because it acts as a "short-circuit"—if the host is missing, it stops right there and returns the error without even trying to parse the port.

def loadDbConfig(raw: Map[String, String]): Either[ConfigError, DbConfig] = {
  for {
    host    <- getValue(raw, "db.host")(identity)
    port    <- getValue(raw, "db.port")(_.toInt)
    timeout <- getValue(raw, "db.timeout")(_.toInt)
  } yield DbConfig(host, port, timeout)
}

I used identity for the host because it's already a string, so we don't need to transform it. Now, if you pass in a map with a missing key, you get a Left(MissingKey("db.host")). No crashes, just a value that tells you exactly what to fix in your application.conf.




📋 Practical Task

Exercise: Build a Service Gateway Configuration Loader

You are integrating with a third-party API and need to load its settings from a Map[String, String]. Your goal is to create a type-safe loader that prevents the application from starting with invalid settings.

  • Requirements:
    • Create a case class ApiConfig with three fields: apiKey (String), retryCount (Int), and isEnabled (Boolean).
    • Implement a sealed trait ApiConfigError with subtypes for MissingSetting and MalformedSetting.
    • Write a loader function loadApiConfig(raw: Map[String, String]): Either[ApiConfigError, ApiConfig].
    • Ensure that the isEnabled field is parsed correctly (hint: String.toBoolean can be used inside your transformation helper).
  • Test Cases to Handle:
    • A map missing the apiKey should return Left(MissingSetting("apiKey")).
    • A map where retryCount is "abc" should return Left(MalformedSetting("retryCount", "abc")).
    • A map with all correct values should return Right(ApiConfig(...)).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.