scalaintermediate

Refined and Constrained Types

Create constrained types with validation: non-empty strings, bounded numbers, and validated domain types.

scala
// Opaque types with validation
object Types:
  // Non-empty string
  opaque type NonEmptyString = String
  object NonEmptyString:
    def apply(s: String): Either[String, NonEmptyString] =
      if s.nonEmpty then Right(s)
      else Left("String must not be empty")

    def unsafe(s: String): NonEmptyString =
      require(s.nonEmpty, "String must not be empty")
      s

    extension (s: NonEmptyString)
      def value: String = s
      def length: Int = s.length

  // Positive integer
  opaque type PosInt = Int
  object PosInt:
    def apply(n: Int): Either[String, PosInt] =
      if n > 0 then Right(n)
      else Left(s"Must be positive: $n")

    extension (n: PosInt)
      def value: Int = n
      def +(other: PosInt): PosInt = (n: Int) + (other: Int)

  // Bounded int
  opaque type Percentage = Int
  object Percentage:
    def apply(n: Int): Either[String, Percentage] =
      if n >= 0 && n <= 100 then Right(n)
      else Left(s"Must be 0-100: $n")

    extension (p: Percentage)
      def value: Int = p
      def asDouble: Double = p / 100.0

  // Email
  opaque type Email = String
  object Email:
    private val emailRegex = """^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$""".r

    def apply(s: String): Either[String, Email] =
      if emailRegex.matches(s) then Right(s)
      else Left(s"Invalid email: $s")

    extension (e: Email)
      def value: String = e
      def domain: String = e.split("@")(1)
      def local: String = e.split("@")(0)

  // Port number
  opaque type Port = Int
  object Port:
    def apply(n: Int): Either[String, Port] =
      if n >= 1 && n <= 65535 then Right(n)
      else Left(s"Invalid port: $n")

    val HTTP: Port = 80
    val HTTPS: Port = 443

    extension (p: Port)
      def value: Int = p
      def isPrivileged: Boolean = p < 1024

import Types.*

// Domain model using refined types
case class ServerConfig(
  host: NonEmptyString,
  port: Port,
  maxRetries: PosInt
)

case class UserProfile(
  name: NonEmptyString,
  email: Email,
  completeness: Percentage
)

// Smart constructor
def createProfile(
  name: String, email: String, completeness: Int
): Either[String, UserProfile] =
  for
    n <- NonEmptyString(name)
    e <- Email(email)
    c <- Percentage(completeness)
  yield UserProfile(n, e, c)

@main def run(): Unit =
  // Valid
  println(NonEmptyString("hello").map(_.value))
  println(PosInt(42).map(_.value))
  println(Email("alice@test.com").map(e => s"${e.local} @ ${e.domain}"))
  println(Percentage(75).map(p => f"${p.asDouble}%.0f%%"))
  println(Port(8080).map(p => s"port ${p.value} privileged=${p.isPrivileged}"))

  // Invalid
  println(NonEmptyString(""))
  println(PosInt(-1))
  println(Email("not-email"))
  println(Percentage(150))
  println(Port(99999))

  // Domain model
  println(s"\nValid profile: ${createProfile("Alice", "alice@test.com", 85)}")
  println(s"Invalid profile: ${createProfile("", "bad", 150)}")

  // Compose
  val config = for
    host <- NonEmptyString("localhost")
    port <- Port(8080)
    retries <- PosInt(3)
  yield ServerConfig(host, port, retries)
  println(s"Config: $config")

Use Cases

  • Domain type validation
  • Compile-time type safety
  • Input validation at boundaries

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.