Last active
October 14, 2025 06:12
-
-
Save shinevit/5cb20f53793aa25df8856f9c09168db9 to your computer and use it in GitHub Desktop.
Minimal validation DSL for Scala 3 with cats-effect integration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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