4

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]]
Alex Fruzenshtein
  • 2,846
  • 6
  • 32
  • 53
  • 1
    What are those strings in the "pseudocode"? Are they supposed to raise errors, or return `String`s? – Andrey Tyukin Jun 30 '19 at 10:38
  • It may be `Left[String]` or `NonEmptyList[String]` as well in terms of scala – Alex Fruzenshtein Jun 30 '19 at 10:40
  • 1
    It might be anything, you could take `Either` and return `Left`/`Right`, or you could take `Validated` and return a list with errors, or you could raise errors in the `IO` itself. It's up to you to decide. Asking for all three variants would be too broad. Also, the title suggests that there should be some IO-actions in both branches of each `if-else`. I think you should reformulate your question and restrict its scope. What is the expected type of `registerUser`? – Andrey Tyukin Jun 30 '19 at 10:47
  • 1
    `registerUser` should has following type `IO[Either[NonEmptyList[String], UserId]]` – Alex Fruzenshtein Jun 30 '19 at 11:00

3 Answers3

2

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
  }
Andrey Tyukin
  • 43,673
  • 4
  • 57
  • 93
2

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.

Mario Galic
  • 47,285
  • 6
  • 56
  • 98
  • 1
    if `userDoesNotAlreadyExist` returns at most one `String` in the list, then `NonEmptyList[String]` with at most one element is exactly the same thing as just a `String`, and should thus be omitted from the signature, lest it invokes the wrong impression that the method might return a more detailed list of error messages. – Andrey Tyukin Jun 30 '19 at 17:37
0

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.

Alex Fruzenshtein
  • 2,846
  • 6
  • 32
  • 53
  • Note that in this implementation, all kinds of network errors, failures to deserialize some messages, failures to access the database etc. are all mixed together with invalid user input. Depending on the use case, this might be not really what you want: you certainly wound want to report to the user if the chosen user name already exists, but you might want to log the failures to access your database, and present the user with a more easily digestible error message. – Andrey Tyukin Jun 30 '19 at 20:56
  • So does it make sense, to represent those exceptions not as `RuntimeException`s but as some custom exceptions related to the domain (e.g. `EntityAlreadyExistException`)? – Alex Fruzenshtein Jun 30 '19 at 21:20
  • That would be similar to using exceptions for control flow. Checking whether a user with a given user name already exists does not feel like "exceptional control flow", so I'd suggest to model it rather like `Either[Error, ?]` or `ValidatedNel[Error, ?]` or something similar, and reserve the `IO.raiseError` for "actual exceptions" (network failures etc.). – Andrey Tyukin Jun 30 '19 at 21:36
  • @AndreyTyukin the only positive feature of "exceptional control flow" in this case is an instant interruption when either 'email is already in use' or 'username is already in use'. Is there any technic apart of throwing an exception, in order to prevent redundant operations? – Alex Fruzenshtein Jul 01 '19 at 04:57
  • 1
    Both `Either` / `EitherT` and `Option` / `OptionT` provide fail-fast behavior, i.e. they interrupt the calculation after the first occurring problem. `Either` provides a "meaningful error message" in `Left`, `Option` simply fails with a `None`. – Andrey Tyukin Jul 01 '19 at 06:51