Search code examples
scalanestedscala-catsscala-option

Avoiding deeply nested Option cascades in Scala


Say I have three database access functions foo, bar, and baz that can each return Option[A] where A is some model class, and the calls depend on each other.

I would like to call the functions sequentially and in each case, return an appropriate error message if the value is not found (None).

My current code looks like this:

Input is a URL: /x/:xID/y/:yID/z/:zID

foo(xID) match {
  case None => Left(s"$xID is not a valid id")
  case Some(x) =>
    bar(yID) match {
      case None => Left(s"$yID is not a valid id")
      case Some(y) =>
        baz(zID) match {
          case None => Left(s"$zID is not a valid id")
          case Some(z) => Right(process(x, y, z))
        }
    }
}

As can be seen, the code is badly nested.

If instead, I use a for comprehension, I cannot give specific error messages, because I do not know which step failed:

(for {
  x <- foo(xID)
  y <- bar(yID)
  z <- baz(zID)
} yield {
  Right(process(x, y, z))
}).getOrElse(Left("One of the IDs was invalid, but we do not know which one"))

If I use map and getOrElse, I end up with code almost as nested as the first example.

Is these some better way to structure this to avoid the nesting while allowing specific error messages?


Solution

  • I came up with this solution (based on @Rex's solution and his comments):

    def ifTrue[A](boolean: Boolean)(isFalse: => A): RightProjection[A, Unit.type] =
      Either.cond(boolean, Unit, isFalse).right
    
    def none[A](option: Option[_])(isSome: => A): RightProjection[A, Unit.type] =
      Either.cond(option.isEmpty, Unit, isSome).right
    
    def some[A, B](option: Option[A])(ifNone: => B): RightProjection[B, A] =
      option.toRight(ifNone).right
    

    They do the following:

    • ifTrue is used when a function returns a Boolean, with true being the "success" case (e.g.: isAllowed(userId)). It actually returns Unit so should be used as _ <- ifTrue(...) { error } in a for comprehension.
    • none is used when a function returns an Option with None being the "success" case (e.g.: findUser(email) for creating accounts with unique email addresses). It actually returns Unit so should be used as _ <- none(...) { error } in a for comprehension.
    • some is used when a function returns an Option with Some() being the "success" case (e.g.: findUser(userId) for a GET /users/userId). It returns the contents of the Some: user <- some(findUser(userId)) { s"user $userId not found" }.

    They are used in a for comprehension:

    for {
      x <- some(foo(xID)) { s"$xID is not a valid id" }
      y <- some(bar(yID)) { s"$yID is not a valid id" }
      z <- some(baz(zID)) { s"$zID is not a valid id" }
    } yield {
      process(x, y, z)
    }
    

    This returns an Either[String, X] where the String is an error message and the X is the result of calling process.