Skip to Content
Course content

99: Smart Constructors for Validation

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

I've spent a lot of time reviewing codebases where "valid" data is just assumed to be valid because it's wrapped in a type. We've all been there: you see a User object, and you assume the email field is actually an email address. But then you hit a runtime error because somewhere, a User("not-an-email") was instantiated and passed through the system.

The "Too Trusting" Case Class

Let's look at a common scenario. I'm building a system for a forum, and I need a Username type. My rules are simple: it can't be empty, and it can't be longer than 20 characters. My first instinct is usually a case class:

case class Username(value: String)

The problem? This is far too trusting. I can do this in my REPL:

val badUser = Username("") 
val wayTooLong = Username("this_username_is_definitely_way_too_long_for_our_db")

Now, badUser is floating around my application. Every single function that takes a Username now has to check if (username.value.isEmpty) just to be safe. That's a leak. The type Username is lying to me; it claims to be a username, but it's actually just a string with no guarantees.

Wait, I'd Rather Not Crash

Okay, so I'll use a require block. I've seen this in plenty of libraries. It stops the object from being created if the condition isn't met:

case class Username(value: String) {
  require(value.nonEmpty && value.length <= 20)
}

I try to create an empty username again, and boom: IllegalArgumentException. While this prevents the "lying" type, it introduces a new problem: crashes. In a production API, I don't want my whole request thread to blow up with an exception because a user typed their name wrong. I want a value I can map over or a message I can send back to the UI.

Closing the Front Door

To fix this, we need to stop the outside world from calling the constructor directly. We can do this by making the constructor private. But wait, if it's private, how do we actually create a Username?

This is where the "Smart Constructor" pattern comes in. We move the creation logic into the companion object and return a type that represents the possibility of failure—like Either.

case class Username private (value: String)

object Username {
  def create(value: String): Either[String, Username] = {
    if (value.isEmpty) {
      Left("Username cannot be empty")
    } else if (value.length > 20) {
      Left("Username is too long")
    } else {
      Right(Username(value))
    }
  }
}

Now, try calling Username("abc"). It won't compile. The constructor is locked. I'm forced to use the create method. Let's see how that feels in practice:

val result = Username.create("") 
// result: Left("Username cannot be empty")

val valid = Username.create("scala_dev") 
// valid: Right(Username("scala_dev"))

Why This Actually Matters

You might think, "Isn't this just more boilerplate?" At first, maybe. But think about the downstream effect. Now, if I have a function like def saveToDb(user: Username), I can 100% guarantee that the user passed into that function is valid. I don't need a single if statement inside saveToDb.

I've shifted the validation to the edge of my system. Once a piece of data passes through the smart constructor, it's "blessed." It's no longer a raw string; it's a verified domain object. This effectively turns runtime errors into compile-time requirements.




📋 Practical Task

Implementing a Validated SKU Identifier

You are building an inventory system. A SKU (Stock Keeping Unit) must follow these strict rules:

  • It must start with the prefix "SKU-".
  • The remaining characters must be exactly 5 digits (e.g., "SKU-12345").
  • It cannot be null or empty.

Your task is to implement a smart constructor for a SKU case class.

  1. Create a SKU case class with a private constructor.
  2. Create a companion object for SKU with a create method.
  3. The create method should return Either[String, SKU].
  4. Return a descriptive error message in the Left side if any of the rules are violated.

Test your implementation with "SKU-12345" (should be Right), "ABC-12345" (should be Left), and "SKU-123" (should be Left).

Rating
0 0

There are no comments for now.

to be the first to leave a comment.