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

Viewed 2309

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]]
3 Answers

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
  }

Consider the following example

object So56824136 extends App {
  type Error = String
  type UserId = String
  type Username = String
  type Email = String
  type Password = String
  case class User(name: String)

  def createUser(username: Username, email: Email, password: Password): IO[Option[UserId]] = IO { Some("100000001")}
  def getUserByUsername(username: Username): IO[Option[User]] = IO { Some(User("picard"))}
  def getUserByEmail(email: Email): IO[Option[User]] = IO { Some(User("picard"))}

  def userDoesNotAlreadyExists(username: Username, email: Email, password: Password): IO[Either[Error, Unit]] =
    (for {
      _ <- OptionT(getUserByUsername(username))
      _ <- OptionT(getUserByEmail(username))
    } yield "User already exists").toLeft().value

  def registerUser(username: Username, email: Email, password: Password) : IO[Either[Error, UserId]] =
    (for {
      _ <- EitherT(userDoesNotAlreadyExists(username, email, password))
      id <- OptionT(createUser(username, email, password)).toRight("Failed to create user")
    } yield id).value

  registerUser("john_doe", "john@example.com", "1111111")
    .unsafeRunSync() match { case v => println(v) }
}

which outputs

Left(User already exists)

Note I have changed the return type of createUser to IO[Option[UserId]], and do not differentiate between user already existing on the basis of email or username, but consider them both as simply user already existing error, hence I use just String on the left instead of NonEmptyList.

Based on Andrey's answer, I developed my own solution for this use case.

    case class User(name: String)

    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]] = ???

    def isExist(condition: Boolean)(msg: String): IO[Unit] =
      if (condition) IO.raiseError(new RuntimeException(msg)) else IO.unit

    def program(username: Username, email: Email, password: Password): IO[Either[String, UserId]] = (for {
      resA <- getUserByUsername(username)
      _ <- isExist(resA.isDefined)("username is already in use")
      resB <- getUserByEmail(email)
      _ <- isExist(resB.isDefined)("email is already in use")
      userId <- createUser(username, email, password)
    } yield {
      userId.asRight[String]
    }).recoverWith {
      case e: RuntimeException => IO.pure(e.getMessage.asLeft[UserId])
    }

Firstly I introduced a helper function isExist(condition: Boolean)(msg: String): IO[Unit]. Its purpose just for checking a fact of existing of username or email (or whatever else). In addition to this, it terminates program execution flow immediately by throwing a RuntimeException with an appropriate message, which can be used later for descriptive response.

Related