Susan Potter
### software  ·  Created  ·  Updated

Flix Part 2: Smart Constructors

In Part 1 , we explored higher-order functions and infix operators in Flix. This installment covers smart constructors: a pattern for enforcing data integrity at construction time.

What are smart constructors?

Smart constructors validate data before allowing construction. Instead of checking constraints after creating an object, you prevent invalid objects from existing in the first place.

The pattern is straightforward: hide the raw constructor and expose a function that validates inputs before constructing the value. If validation fails, the function returns an error or None rather than an invalid instance.

A Motivating Example

Consider a User type with these constraints:

  • username: 4-32 characters
  • name: non-empty
  • dob: at least 13 years ago
  • bio: at most 500 characters

In Scala 3, we might define the type and a naive constructor:

case class User(username: String, name: String, dob: LocalDate, bio: String)

// Naive approach: validate then construct
def makeUser(username: String, name: String, dob: LocalDate, bio: String): User =
  if username.length < 4 || username.length > 32 then
    throw IllegalArgumentException("username must be 4-32 characters")
  else if name.isEmpty then
    throw IllegalArgumentException("name cannot be empty")
  else if Period.between(dob, LocalDate.now()).getYears < 13 then
    throw IllegalArgumentException("user must be at least 13 years old")
  else if bio.length > 500 then
    throw IllegalArgumentException("bio cannot exceed 500 characters")
  else
    User(username, name, dob, bio)

This approach has problems:

  • Exceptions for validation errors force callers to handle them or crash
  • Nothing prevents direct User(...) construction, bypassing validation
  • The raw constructor remains exposed

Smart Constructors in Scala 3

A better approach returns Option or Either and hides the raw constructor:

// Hide the constructor with private
case class User private (username: String, name: String, dob: LocalDate, bio: String)

object User:
  def make(
    username: String,
    name: String,
    dob: LocalDate,
    bio: String
  ): Either[String, User] =
    if username.length < 4 || username.length > 32 then
      Left("username must be 4-32 characters")
    else if name.isEmpty then
      Left("name cannot be empty")
    else if Period.between(dob, LocalDate.now()).getYears < 13 then
      Left("user must be at least 13 years old")
    else if bio.length > 500 then
      Left("bio cannot exceed 500 characters")
    else
      Right(User(username, name, dob, bio))

Now callers must handle the validation result:

User.make("mbbx6spp", "Susan", LocalDate.of(1980, 1, 1), "Flix enthusiast") match
  case Right(user) => println(s"Created user: ${user.username}")
  case Left(error) => println(s"Validation failed: $error")

Smart Constructors in Flix

Flix provides the same pattern with slightly different syntax. Here is the equivalent:

mod User {
    pub enum User({username = String, name = String, dob = Int32, bio = String})

    pub def make(
        username: String,
        name: String,
        dob: Int32,
        bio: String
    ): Result[String, User] =
        let currentYear = 2024;  // simplified for example
        let age = currentYear - dob;
        if (String.length(username) < 4 or String.length(username) > 32)
            Err("username must be 4-32 characters")
        else if (String.isEmpty(name))
            Err("name cannot be empty")
        else if (age < 13)
            Err("user must be at least 13 years old")
        else if (String.length(bio) > 500)
            Err("bio cannot exceed 500 characters")
        else
            Ok(User.User({username = username, name = name, dob = dob, bio = bio}))
}

Flix uses Result[E, A] where Scala uses Either[L, R]. The Ok and Err constructors correspond to Right and Left.

Refining Types Further

Smart constructors validate at runtime. For stronger guarantees, both Scala 3 and Flix support opaque types that prevent invalid values at the type level.

In Scala 3:

opaque type Username = String

object Username:
  def make(s: String): Option[Username] =
    if s.length >= 4 && s.length <= 32 then Some(s)
    else None

  extension (u: Username)
    def value: String = u

In Flix:

mod Username {
    pub opaque type Username = String

    pub def make(s: String): Option[Username] =
        if (String.length(s) >= 4 and String.length(s) <= 32)
            Some(s)
        else
            None

    pub def value(u: Username): String = u
}

With opaque types, Username and String are distinct types. Code cannot accidentally pass a raw string where a validated username is expected.

Combining with the Calculator Example

Returning to our calculator from Parts 0 and 1, we can apply smart constructors to prevent division by zero:

mod SafeCalc {
    pub enum Expr {
        case Lit(Int32),
        case Add(Expr, Expr),
        case Mul(Expr, Expr),
        case Div(Expr, Expr),
        case Sub(Expr, Expr)
    }

    /// Smart constructor for division that checks for zero divisor
    pub def safeDiv(left: Expr, right: Expr): Result[String, Expr] =
        match right {
            case Expr.Lit(0) => Err("division by zero")
            case _           => Ok(Expr.Div(left, right))
        }

    pub def eval(expr: Expr): Result[String, Int32] = match expr {
        case Expr.Lit(i)      => Ok(i)
        case Expr.Add(e1, e2) => Result.flatMap(eval(e1), v1 ->
                                 Result.map(eval(e2), v2 -> v1 + v2))
        case Expr.Mul(e1, e2) => Result.flatMap(eval(e1), v1 ->
                                 Result.map(eval(e2), v2 -> v1 * v2))
        case Expr.Div(e1, e2) => Result.flatMap(eval(e1), v1 ->
                                 Result.flatMap(eval(e2), v2 ->
                                     if (v2 == 0) Err("division by zero") else Ok(v1 / v2)))
        case Expr.Sub(e1, e2) => Result.flatMap(eval(e1), v1 ->
                                 Result.map(eval(e2), v2 -> v1 - v2))
    }
}

The safeDiv smart constructor catches literal zero divisors at construction time. The eval function handles runtime division by zero for computed values.

Summary

Smart constructors provide a systematic way to enforce data integrity:

  1. Hide the raw constructor (private in Scala, module encapsulation in Flix)
  2. Expose a function that validates inputs
  3. Return Option, Either, or Result instead of throwing exceptions
  4. Consider opaque types for even stronger compile-time guarantees

Both Scala 3 and Flix support this pattern with similar ergonomics. The main differences are syntactic: Flix uses Result[E, A] with Ok/Err, while Scala uses Either[L, R] with Right/Left.

Smart constructors eliminate entire classes of bugs by making invalid states unrepresentable. Combined with exhaustive pattern matching (covered in Part 0), they form a foundation for building reliable software.