DEV Community

AranaDeDoros
AranaDeDoros

Posted on

Smart Constructors in Scala 3: Validate Once, Trust Everywhere

Opaque types give you shape safety, but not value safety. Here's how smart constructors and ValidatedNec close the gap.

This lesson assumes you have used Either or have a brief exposition to the cats library.

You've wrapped your primitives in opaque types and modeled your domain with ADTs. Nice. But there's still a hole: if the constructor is public, nothing stops someone from writing Email("lol") or Age(-5).

Types give you shape safety. They don't give you value safety. For that you need smart constructors.

What's a smart constructor?

It's just a function (usually called parse, of, from or apply) that is the only way to create a value of a type, and that validates its input on the way in. Hide the raw constructor and you get a value that is correct by construction: if you're holding one, someone already checked it. No more isValid() sprinkled across fifteen call sites.

opaque type Age = Int

object Age:
  def parse(raw: Int): Either[String, Age] =
    if raw >= 0 && raw <= 150 then Right(raw)
    else Left(s"$raw is not a realistic age")

  extension (a: Age) def value: Int = a
Enter fullscreen mode Exit fullscreen mode

There's no Age.apply that skips the check. Outside the companion object, the only road to an Age goes through parse.

Validating a whole form

Here's the pattern you'll reuse constantly: several small smart constructors, combined with mapN, validating an entire form while collecting every error, not just the first one.

import cats.data.ValidatedNec
import cats.syntax.all.*

opaque type Handle = String
opaque type Email = String

object Handle:
  def parse(raw: String): ValidatedNec[String, Handle] =
    if raw.length >= 3 && raw.length <= 20 && raw.forall(_.isLetterOrDigit)
    then raw.validNec
    else "handle must be 3-20 letters or digits".invalidNec

object Email:
  private val pattern = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$".r
  def parse(raw: String): ValidatedNec[String, Email] =
    if pattern.matches(raw) then raw.validNec
    else s"'$raw' doesn't look like an email".invalidNec

final case class Signup(handle: Handle, email: Email, age: Age)

object Signup:
  def validate(
      rawHandle: String,
      rawEmail: String,
      rawAge: Int
  ): ValidatedNec[String, Signup] =
    (
      Handle.parse(rawHandle),
      Email.parse(rawEmail),
      Age.parse(rawAge).toValidatedNec
    ).mapN(Signup.apply)
Enter fullscreen mode Exit fullscreen mode

Feed it garbage and you get every problem back at once, which your frontend will love:

Signup.validate("x", "nope", -5)
// Invalid(Chain(
//   "handle must be 3-20 letters or digits",
//   "'nope' doesn't look like an email",
//   "-5 is not a realistic age"
// ))
Enter fullscreen mode Exit fullscreen mode

Notice that Age.parse returns an Either, and we simply lifted it with .toValidatedNec. Converting between the two is cheap.

Either or Validated?

  • Either: when checks depend on each other. If step 2 can't run without step 1 (parse the ID, then look it up), short-circuiting is exactly what you want.
  • ValidatedNec: when checks are independent and you want all the errors at once. Form and config validation are the textbook cases.

Don't agonize over it. Pick the one that matches how your checks actually relate, and convert if you change your mind.

A pattern that works well: use ValidatedNec at the edges (HTTP decoding, config loading) and Either once you're inside business logic.

Gotchas

  • The raw constructor must really be hidden. With opaque types that's automatic outside the companion. With case classes, you need a private apply (or a sealed abstract case class), or someone will find the backdoor within a week.
  • Don't validate the same thing twice. If Email.parse already guarantees validity, re-checking in the service layer "just in case" means you don't trust your own type. Fix the type, not the call site.
  • Keep smart constructors pure. "Is this handle already taken?" needs I/O, so that belongs in the service layer, not in the constructor.

The takeaway

Validate at the boundary of your system (HTTP, config, DB deserialization) and let everything inside assume its inputs are already correct. Validate once, trust everywhere else.


Want more like this?

This post is adapted from a chapter of my book, Scala 3 Domain Design and TypeStack Cookbook, available on Leanpub.

The book has two parts:

  1. Domain design in Scala 3: opaque types, ADTs, state machines, smart constructors and more, with real-life examples and a not-too-serious tone.
  2. TypeStack recipes (cats, cats-effect, http4s): copy-pasteable backend recipes like rate limiting, JWT, setting up a server and merging routers.

I come from Node and Python (with some Java/C#), so the book is written from the perspective of someone learning to make Scala's type system work for them, not from a long-time Scala veteran. If you're at an intermediate level and want practical patterns instead of theory, it's for you.

Questions or feedback? Drop a comment below. 👇

Top comments (0)