scalaintermediate

Error Accumulation with Validated

Accumulate multiple validation errors instead of failing fast using Cats Validated and custom validators.

scala
import cats.data.*
import cats.syntax.all.*

// Domain types
case class RegistrationForm(
  username: String,
  email: String,
  password: String,
  age: String
)

case class ValidatedUser(
  username: String,
  email: String,
  passwordHash: String,
  age: Int
)

sealed trait ValidationError:
  def message: String

case class EmptyField(field: String) extends ValidationError:
  def message = s"$field cannot be empty"

case class TooShort(field: String, min: Int, actual: Int) extends ValidationError:
  def message = s"$field must be at least $min chars (got $actual)"

case class InvalidFormat(field: String, hint: String) extends ValidationError:
  def message = s"$field: $hint"

case class OutOfRange(field: String, min: Int, max: Int) extends ValidationError:
  def message = s"$field must be between $min and $max"

type VResult[A] = ValidatedNel[ValidationError, A]

// Individual validators
def validateUsername(s: String): VResult[String] =
  if s.isEmpty then EmptyField("username").invalidNel
  else if s.length < 3 then TooShort("username", 3, s.length).invalidNel
  else if !s.matches("^[a-zA-Z0-9_]+$") then
    InvalidFormat("username", "alphanumeric and underscore only").invalidNel
  else s.validNel

def validateEmail(s: String): VResult[String] =
  if s.isEmpty then EmptyField("email").invalidNel
  else if !s.matches(".+@.+\\..+") then
    InvalidFormat("email", "must be a valid email").invalidNel
  else s.validNel

def validatePassword(s: String): VResult[String] =
  val errors = List(
    Option.when(s.length < 8)(TooShort("password", 8, s.length)),
    Option.when(!s.exists(_.isUpper))(InvalidFormat("password", "must contain uppercase")),
    Option.when(!s.exists(_.isDigit))(InvalidFormat("password", "must contain a digit"))
  ).flatten

  NonEmptyList.fromList(errors) match
    case Some(nel) => Validated.invalid(nel)
    case None      => s.hashCode.toString.validNel  // fake hash

def validateAge(s: String): VResult[Int] =
  s.toIntOption match
    case None    => InvalidFormat("age", "must be a number").invalidNel
    case Some(a) if a < 13 || a > 120 => OutOfRange("age", 13, 120).invalidNel
    case Some(a) => a.validNel

def validate(form: RegistrationForm): VResult[ValidatedUser] =
  (
    validateUsername(form.username),
    validateEmail(form.email),
    validatePassword(form.password),
    validateAge(form.age)
  ).mapN(ValidatedUser.apply)

@main def run(): Unit =
  // Valid form
  val good = RegistrationForm("alice_42", "alice@test.com", "Secret123", "25")
  println(s"Good: ${validate(good)}")

  // All fields invalid — accumulates ALL errors
  val bad = RegistrationForm("", "not-email", "short", "abc")
  validate(bad) match
    case Validated.Invalid(errors) =>
      println("Errors:")
      errors.toList.foreach(e => println(s"  - ${e.message}"))
    case Validated.Valid(_) => println("Valid")

  // Some fields invalid
  val partial = RegistrationForm("ab", "alice@test.com", "no", "10")
  validate(partial) match
    case Validated.Invalid(errors) =>
      println(s"Partial errors (${errors.size}):")
      errors.toList.foreach(e => println(s"  - ${e.message}"))
    case _ => ()

Use Cases

  • Form validation with all errors
  • Input validation pipelines
  • API request validation

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.