Skip to content

Instantly share code, notes, and snippets.

@shinevit
Last active October 14, 2025 06:12
Show Gist options
  • Select an option

  • Save shinevit/5cb20f53793aa25df8856f9c09168db9 to your computer and use it in GitHub Desktop.

Select an option

Save shinevit/5cb20f53793aa25df8856f9c09168db9 to your computer and use it in GitHub Desktop.
Minimal validation DSL for Scala 3 with cats-effect integration
#!/usr/bin/env -S scala-cli shebang
//> using scala "3.7.3"
//> using dep "org.typelevel::cats-core:2.13.0"
//> using dep "org.typelevel::cats-effect:3.6.3"
//
// Guard DSL 🛡️
// A minimal, fluent, and lazily evaluated validation DSL for Scala 3,
// with configurable error handling.
// Designed for composable argument guarding, error accumulation,
// and clean integration with cats-effect.
//
// Author: Vitalii Radchenko
// Email: [email protected]
// Twitter: @shine_vi
// Date: October 8, 2025
//
object dsl:
object guard:
object validation:
import cats.MonadThrow
import cats.syntax.all.*
import domain.*
object domain:
import scala.util.control.NoStackTrace
final case class InvalidArgumentError(errors: Vector[String])
extends Exception(errors.mkString("Validation failed:\n - ", "\n - ", ""))
with NoStackTrace
trait ErrorFactory:
def toThrowable(errors: Vector[String]): Throwable
object ErrorFactory:
given defaultErrorFactory: ErrorFactory:
def toThrowable(errors: Vector[String]): Throwable =
InvalidArgumentError(errors)
final private[guard] case class GuardState[+E, +A](
value: Option[A],
errors: Vector[E],
):
def isSuccess: Boolean =
errors.isEmpty
object GuardState:
def empty[E, T]: GuardState[E, T] =
GuardState(Option.empty[T], Vector.empty[E])
trait StatefulGuardEvaluator[F[_]]:
private[guard] val errors: Vector[String]
def eval(
)(using
F: MonadThrow[F],
factory: ErrorFactory,
): F[Unit] =
if errors.nonEmpty
then F.raiseError(factory.toThrowable(errors))
else F.unit
trait StatelessGuardEvaluator[F[_], A]:
def eval(
state: GuardState[String, A]
)(using
F: MonadThrow[F],
factory: ErrorFactory,
): F[Unit] =
if !state.isSuccess
then F.raiseError(factory.toThrowable(state.errors))
else F.unit
private[validation] object GuardFriend:
def require[E, A](
state: GuardState[E, A],
condition: => Boolean,
message: => E,
): GuardState[E, A] =
if condition
then state
else state.copy(errors = state.errors :+ message)
def requireOpt[T, E, A](
state: GuardState[E, A],
opt: => Option[T],
predicate: T => Boolean,
message: => E,
): GuardState[E, A] =
opt match
case Some(value) if predicate(value) =>
state
case _ =>
state.copy(errors = state.errors :+ message)
def andThen[A](
state: GuardState[String, ?],
next: () => A,
skip: A,
): A =
if state.isSuccess then next()
else skip
object fluent:
import validation.*
export syntax.*
trait Guard[F[_]] extends StatefulGuardEvaluator[F]:
private[fluent] val state: GuardState[String, Unit]
def require(condition: => Boolean)(message: => String): Guard[F]
def requireOpt[A](
opt: => Option[A]
)(
predicate: A => Boolean
)(
message: => String
): Guard[F]
object Guard:
export GuardOps.*
def apply[F[_]]: Guard[F] =
apply(GuardState(None, Vector.empty))
private[validation] def apply[F[_]](
initialState: GuardState[String, Unit]
): Guard[F] =
new Guard[F]:
override val errors: Vector[String] =
initialState.errors
override val state: GuardState[String, Unit] =
initialState
override def require(condition: => Boolean)(message: => String): Guard[F] =
apply(GuardFriend.require(state, condition, message))
override def requireOpt[A](
opt: => Option[A]
)(
predicate: A => Boolean
)(
message: => String
): Guard[F] =
apply(GuardFriend.requireOpt(state, opt, predicate, message))
object GuardOps:
type GuardIdentityFn[F[_]] =
Guard[F] => Guard[F]
extension [F[_]](guard: Guard[F])
def andThen(next: GuardIdentityFn[F]): Guard[F] =
GuardFriend.andThen(guard.state, () => next(guard), guard)
object syntax:
import GuardOps.GuardIdentityFn
def guard[F[_]: MonadThrow](
block: GuardIdentityFn[F]
)(using
ErrorFactory
): F[Unit] =
block(Guard[F]).eval()
object monadic:
import cats.data.State
import cats.Eval
import validation.*
type GuardContext[F[_]] =
GuardState[String, Guard[F]]
/*
* Stateless Guard.
* It works without rebinding variables on for-comprehension.
*/
trait Guard[F[_]] extends StatelessGuardEvaluator[F, Guard[F]]:
def require(condition: => Boolean)(message: => String): State[GuardContext[F], Unit]
def requireOpt[A](
opt: => Option[A]
)(
predicate: A => Boolean
)(
message: => String
): State[GuardContext[F], Unit]
object Guard:
import cats.syntax.option.*
def apply[F[_]]: State[GuardContext[F], Guard[F]] =
pure(create())
private def pure[G, S](g: G): State[S, G] =
State.pure(g)
private[monadic] def handleState[F[_]: MonadThrow](
stateMonad: State[GuardContext[F], Unit]
): F[Unit] =
val state = stateMonad.runS(GuardState.empty).value
state.value match
case Some(guard) => guard.eval(state)
case None => MonadThrow[F].unit
private[monadic] def updateState[F[_]](
s: GuardContext[F],
guard: Guard[F],
): GuardContext[F] =
s.copy(value = s.value.getOrElse(guard).some)
private def create[F[_]](): Guard[F] =
new Guard[F]:
self =>
override def require(
condition: => Boolean
)(
message: => String
): State[GuardContext[F], Unit] =
State.modify { s =>
GuardFriend.require(updateState(s, self), condition, message)
}
override def requireOpt[A](
opt: => Option[A]
)(
predicate: A => Boolean
)(
message: => String
): State[GuardContext[F], Unit] =
State.modify { s =>
GuardFriend.requireOpt(updateState(s, self), opt, predicate, message)
}
object GuardOps:
import Guard.{ handleState, updateState }
import cats.syntax.option.*
extension [F[_]](guard: Guard[F])
/** DSL smart method is called in case of successful validation
* of the previous step
*
* @param next
* @return
*/
def andThen(
next: Guard[F] => State[GuardContext[F], Unit]
): State[GuardContext[F], Unit] =
State.modify { s =>
val newState = updateState(s, guard)
GuardFriend.andThen(
newState,
() => next(guard).runS(s).value,
newState,
)
}
extension [F[_]: MonadThrow](stateMonad: State[GuardContext[F], Unit])
def eval(): F[Unit] = handleState(stateMonad)
object syntax:
import cats.MonadThrow
import Guard.handleState
export monadic.GuardOps.*, cats.syntax.flatMap.*
def guard[F[_]: MonadThrow](block: Guard[F] => State[GuardContext[F], Unit]): F[Unit] =
handleState(Guard[F].flatMap(block))
object effectful:
import cats.Monad
import cats.data.{ State, StateT }
import validation.*
type RunnableState[F[_], S] =
StateT[F, S, Unit]
type GuardContext[F[_]] =
GuardState[String, GuardT[F]]
/** Stateless and effectful guard.
* It works without rebinding variables on for-comprehension
*/
trait GuardT[F[_]] extends StatelessGuardEvaluator[F, GuardT[F]]:
def require(condition: => Boolean)(message: => String): StateT[F, GuardContext[F], Unit]
def requireOpt[A](
opt: => Option[A]
)(
predicate: A => Boolean
)(
message: => String
): StateT[F, GuardContext[F], Unit]
def requireF[A](
valueF: F[A]
)(
predicate: A => Boolean
)(
message: => String
): StateT[F, GuardContext[F], Unit]
object GuardT:
import cats.syntax.option.*
export cats.syntax.flatMap.*, GuardFOps.*
def apply[F[_]: MonadThrow]: StateT[F, GuardContext[F], GuardT[F]] =
pure(create[F])
private[effectful] def pure[F[_]: Monad, G, S](g: G): StateT[F, S, G] =
StateT.pure(g)
private[effectful] def handleState[F[_]: MonadThrow](
stateMonad: RunnableState[F, GuardContext[F]]
): F[Unit] =
stateMonad
.runS(GuardState.empty)
.flatMap { state => // accumulated GuardState
state.value match
case Some(guard) => guard.eval(state)
case None => MonadThrow[F].unit
}
private[effectful] def updateState[F[_]](
s: GuardContext[F],
guard: GuardT[F],
): GuardContext[F] =
s.copy(value = s.value.getOrElse(guard).some)
private class GuardEffectful[F[_]: MonadThrow]() extends GuardT[F]:
self =>
override def require(
condition: => Boolean
)(
message: => String
): StateT[F, GuardContext[F], Unit] =
StateT.modify { s =>
GuardFriend.require(updateState(s, self), condition, message)
}
override def requireOpt[A](
opt: => Option[A]
)(
predicate: A => Boolean
)(
message: => String
): StateT[F, GuardContext[F], Unit] =
StateT.modify { s =>
GuardFriend.requireOpt(updateState(s, self), opt, predicate, message)
}
override def requireF[A](
valueF: F[A]
)(
predicate: A => Boolean
)(
message: => String
): StateT[F, GuardContext[F], Unit] =
StateT.liftF(valueF).flatMap { value =>
StateT.modify { s =>
GuardFriend.require(updateState(s, self), predicate(value), message)
}
}
private[effectful] def create[F[_]: MonadThrow]: GuardT[F] =
new GuardEffectful[F]()
object GuardFOps:
import cats.data.StateT
import GuardT.{ handleState, updateState }
extension [F[_]: Monad](guard: GuardT[F])
def andThen(
next: GuardT[F] => StateT[F, GuardContext[F], Unit]
): StateT[F, GuardContext[F], Unit] =
StateT { s =>
val newState = updateState(s, guard)
GuardFriend.andThen(
newState,
() => next(guard).run(s),
Monad[F].pure((newState, ())),
)
}
def liftF[A, S](f: F[A]): StateT[F, S, A] = StateT.liftF(f)
def liftState[S, A](
state: State[S, A]
): StateT[F, S, A] = StateT.fromState(state.map(Monad[F].pure))
extension [F[_]: MonadThrow](state: RunnableState[F, GuardContext[F]])
def eval(): F[Unit] = handleState(state)
object syntax:
import GuardT.handleState
export GuardFOps.*, cats.syntax.flatMap.*
def guard[F[_]: MonadThrow](
block: GuardT[F] => RunnableState[F, GuardContext[F]]
): F[Unit] =
handleState(block(GuardT.create[F]))
object example:
import cats.Show
import cats.MonadThrow
import cats.effect.{ IO, ExitCode }
import cats.syntax.all.*
import scala.util.matching.Regex
import dsl.guard.validation
import validation.*
import validation.domain.*
export User.*
case class User(
name: String,
age: Int,
email: Option[String] = None,
address: String = "",
)
object User:
given Show[User] =
Show.show { user =>
s"User(name=${user.name}, age=${user.age}, email=${user.email.getOrElse("none")}, address=${user.address})"
}
val EmailRegex: Regex =
"^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$".r
def checkEmail(email: String): Boolean =
EmailRegex.matches(email)
def withNewLine[A](
block: IO[A]
)(using
countNewLines: Int = 1
): IO[A] =
block.flatMap { a =>
IO.print("\n" * countNewLines).as(a)
}
private def handleError(block: IO[Unit]): IO[ExitCode] =
block
.as(ExitCode.Success)
.handleErrorWith {
case err: InvalidArgumentError =>
IO.println(err.getMessage())
.as(ExitCode.Error)
}
def runGuardFluentRegular(user: User): IO[ExitCode] =
import validation.fluent.Guard
def validateUser(user: User): Guard[IO] =
Guard[IO]
.require(user.name.nonEmpty)("Name mustn't be empty")
.require(user.age > 0)("Age must be a natural number")
.require(user.age <= 120)("Age should be a realistic value")
.requireOpt(user.email)(_.nonEmpty)("Email shouldn't be empty") // if it's defined
.andThen(_.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax"))
.require(user.address.nonEmpty)("Address shouldn't be empty")
handleError {
for
_ <- IO.println("Example #1: with fluent `Guard`...")
_ <- IO.println(s"Validating user: ${user.show}...")
_ <- validateUser(user).eval()
_ <- IO.println("#1: User has been created successfully")
yield ()
}
def runGuardFluentSyntax(
user: User
)(using
MonadThrow[IO]
): IO[ExitCode] =
import validation.fluent.syntax.*
def validateUser(user: User): IO[Unit] =
guard { g =>
g.require(user.name.nonEmpty)("Name mustn't be empty")
.require(user.age > 0)("Age must be a natural number")
.require(user.age <= 120)("Age should be a realistic value")
.requireOpt(user.email)(_.nonEmpty)("Email shouldn't be empty") // if it's defined
.andThen(_.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax"))
.require(user.address.nonEmpty)("Address shouldn't be empty")
}
handleError {
for
_ <- IO.println("Example #2: with fluent `guard` syntax...")
_ <- IO.println(s"Validating user: ${user.show}...")
_ <- validateUser(user)
_ <- IO.println("#2: User has been created successfully")
yield ()
}
def runGuardMonadicRegular(user: User): IO[ExitCode] =
import validation.monadic.Guard
import validation.monadic.syntax.*
def validateUser(user: User): IO[Unit] =
(for
g <- Guard[IO]
_ <- g.require(user.name.nonEmpty)("Name mustn't be empty")
_ <- g.require(user.age > 0)("Age must be a natural number")
_ <- g.require(user.age <= 120)("Age should be a realistic value")
_ <- g.requireOpt(user.email)(_.nonEmpty)("Email shouldn't be empty")
_ <- g.andThen(_.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax"))
_ <- g.require(user.address.nonEmpty)("Address shouldn't be empty")
yield ()).eval()
handleError {
for
_ <- IO.println("Example #3: with monadic `Guard`...")
_ <- IO.println(s"Validating user: ${user.show}...")
_ <- validateUser(user)
_ <- IO.println("#3: User has been created successfully")
yield ()
}
def runGuardMonadicSyntax(user: User): IO[ExitCode] =
import validation.monadic.syntax.*
def validateUser(user: User): IO[Unit] =
guard { g =>
for
_ <- g.require(user.name.nonEmpty)("Name mustn't be empty")
_ <- g.require(user.age > 0)("Age must be a natural number")
_ <- g.require(user.age <= 120)("Age should be a realistic value")
_ <- g.requireOpt(user.email)(_.nonEmpty)("Email shouldn't be empty")
_ <- g.andThen(_.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax"))
_ <- g.require(user.address.nonEmpty)("Address shouldn't be empty")
yield ()
} // eval() is called by default exiting the scope
handleError {
for
_ <- IO.println("Example #4: with monadic `guard` syntax...")
_ <- IO.println(s"Validating user: ${user.show}...")
_ <- validateUser(user) // .eval() is not required to call()
_ <- IO.println("#4: User has been created successfully")
yield ()
}
def runGuardEffectfulRegular(user: User): IO[ExitCode] =
import validation.effectful.*
def validateUser(user: User): IO[Unit] =
(for
g <- GuardT[IO]
_ <- g.liftF(IO.println(s"Validating user: ${user.show}..."))
_ <- g.require(user.name.nonEmpty)("Name mustn't be empty")
_ <- g.require(user.age > 0)("Age must be a natural number")
_ <- g.require(user.age <= 120)("Age should be a realistic value")
_ <- g.requireOpt(user.email)(_.nonEmpty)("Email shouldn't be empty")
_ <- g.andThen(_.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax"))
_ <- g.liftF(IO.println("Validating user address"))
_ <- g.requireF(IO(user.address))(_.nonEmpty)("Address shouldn't be empty")
yield ()).eval()
handleError {
for
_ <- IO.println("Example #5: with effectful regular dsl...")
_ <- validateUser(user)
_ <- IO.println("#5: User has been created successfully")
yield ()
}
def runGuardEffectfulSyntax(user: User): IO[ExitCode] =
import validation.effectful.syntax.*
def validateUser(user: User): IO[Unit] =
guard { g =>
for
_ <- g.liftF(IO.println(s"Validating user: ${user.show}..."))
_ <- g.require(user.name.nonEmpty)("Name mustn't be empty")
_ <- g.require(user.age > 0)("Age must be a natural number")
_ <- g.require(user.age <= 120)("Age should be a realistic value")
_ <- g.requireOpt(user.email)(_.nonEmpty)("Email shouldn't be empty")
_ <-
g.andThen { g =>
g.liftF(IO.println("Validating email syntax")) >>
g.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax")
}
// or
// _ <-
// g.andThen { g =>
// for
// _ <- g.liftF(IO.println("Validating email syntax"))
// _ <- g.requireOpt(user.email)(User.checkEmail(_))("Invalid email syntax")
// yield ()
// }
//
_ <- g.requireF(IO(user.address))(_.nonEmpty)("Address shouldn't be empty")
yield ()
}
handleError {
for
_ <- IO.println("Example #6: with effectful `guard` syntax...")
_ <- validateUser(user)
_ <- IO.println("#6: User has been created successfully")
yield ()
}
end example
object app:
import cats.effect.{ IO, IOApp, ExitCode }
import example.*
import example.withNewLine as L
object AppExample extends IOApp:
val mainProgramIO =
for
example1 <- L(runGuardFluentRegular(User("", 0, Some(""))))
example2 <- L(runGuardFluentSyntax(User("", 130, Some("user.me"), "123 Main St")))
example3 <- L(runGuardMonadicRegular(User("John", 0)))
example4 <- L(runGuardMonadicSyntax(User("John", 30, Some("[email protected]"))))
example5 <- L(runGuardEffectfulRegular(User("John", 120)))
example6 <-
L(runGuardEffectfulSyntax(User("John", 30, Some("[email protected]"), "123 Main St")))
yield List(example1, example2, example3, example4, example5, example6)
def showStats(listCodes: List[ExitCode]): IO[Unit] =
val (failures, successes) = listCodes.partition(_.code > 0)
IO.println(f"Successful: ${successes.size}, Failures: ${failures.size}")
def run(args: List[String]): IO[ExitCode] =
for
codes <- mainProgramIO
_ <- showStats(codes)
(failures, successes) = codes.partition(_.code > 0)
yield
if successes.size > failures.size
then ExitCode.Success
else failures.head
app.AppExample.main(args.toArray)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment