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 charactersname: non-emptydob: at least 13 years agobio: 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:
- Hide the raw constructor (private in Scala, module encapsulation in Flix)
- Expose a function that validates inputs
- Return
Option,Either, orResultinstead of throwing exceptions - 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.