Search code examples
scalascala-cats

How to implement if-else logic with Cats IO monad?


What is a correct way to implement if-else logic with Cats IO monad?

Here is a basic example with user registration flow described with pseudo code:

registerUser(username, email, password) = {
  if (findUser(username) == 1) "username is already in use"
  else if (findUser(email) == 1) "email is already in use"
  else saveUser(username, email, password)
}

How to implement the same logic in terms of Scala Cats IO monad?

  def createUser(username: Username, email: Email, password: Password): IO[Unit]
  def getUserByUsername(username: Username): IO[Option[User]]
  def getUserByEmail(email: Email): IO[Option[User]]

Solution

  • Since you want a NonEmptyList of errors, it seems that you have to combine the results of getUserByUsername and getUserByEmail with Validated, and only later convert it into an Either. On this Either, you can then invoke a fold with some IOs in both branches. It was too awkward to combine it in one for-comprehension, so I separated it into two methods:

    import cats.data.Validated.condNel
    import cats.data.NonEmptyList
    import cats.syntax.apply._
    import cats.syntax.either._
    import cats.effect._
    
    case class User(name: String)
    
    trait CreateUserOnlyIfNoCollision {
    
      type Username = String
      type Email = String
      type Password = String
      type ErrorMsg = String 
      type UserId = Long
    
      def createUser(username: Username, email: Email, password: Password): IO[UserId]
      def getUserByUsername(username: Username): IO[Option[User]]
      def getUserByEmail(email: Email): IO[Option[User]]
    
      /** Attempts to get user both by name and by email,
        * returns `()` if nothing is found, otherwise
        * returns a list of error messages that tell whether
        * name and/or address are already in use.
        */
      def checkUnused(username: Username, email: Email)
      : IO[Either[NonEmptyList[String], Unit]] = {
        for {
          o1 <- getUserByUsername(username)
          o2 <- getUserByEmail(email)
        } yield {
          (
            condNel(o1.isEmpty, (), "username is already in use"),
            condNel(o2.isEmpty, (), "email is already in use")
          ).mapN((_, _) => ()).toEither
        }
      }
    
      /** Attempts to register a user.
        * 
        * Returns a new `UserId` in case of success, or 
        * a list of errors if the name and/or address are already in use.
        */
      def registerUser(username: Username, email: Email, password: Password)
      : IO[Either[NonEmptyList[String], UserId]] = {
        for {
          e <- checkUnused(username, email)
          res <- e.fold(
            errors => IO.pure(errors.asLeft),
            _ => createUser(username, email, password).map(_.asRight)
          )
        } yield res
      }
    }
    

    Something like this maybe?

    Or alternatively with EitherT:

      def registerUser(username: Username, email: Email, password: Password)
      : IO[Either[Nel[String], UserId]] = {
        (for {
          e <- EitherT(checkUnused(username, email))
          res <- EitherT.liftF[IO, Nel[String], UserId](
            createUser(username, email, password)
          )
        } yield res).value
      }
    

    or:

      def registerUser(username: Username, email: Email, password: Password)
      : IO[Either[Nel[String], UserId]] = {
        (for { 
          e <- EitherT(checkUnused(username, email))
          res <- EitherT(
            createUser(username, email, password).map(_.asRight[Nel[String]])
          )
        } yield res).value
      }