How to refactor (if / elsif / elsif) chain in Scala?

Recently I had the same annoying multiple if-else block which looked terrible
I come up with the next options:

Option 1:
The simplest approach is to introduce a separate function for each if-else block, for an example condition I'm just comparing an integer constant with a literal, but you can substitute it with anything else

val x = 3

def check1: Option[String] = {
  if (x == 1) Some("error 1") else None
}

def check2: Option[String] = {
  if (x == 2) Some("error 2") else None
}

def check3: Option[String] = {
  if (x == 3) Some("error 3") else None
}

// we can chain Option results together
// using "orElse" function
val result = check1
  .orElse(check2)
  .orElse(check3)

// result contains a returned value from one
// of the above functions,
// or if no checks worked, it ends up with "Option.None"
println(result.getOrElse("passed"))

Refactored code as it is, looks much better than multiple if-else statements, now we can give each function a reasonable name, and, in my case, it eliminated cyclomatic complexity warnings from style checker

Option 2:
The first approach still had "else" part and I wanted to get rid of it at all costs, so I used partial functions

// just an alias, so I don't need to write
// the full parameter type for every function
type Validator = PartialFunction[Int, Option[String]]

def check1: Validator = { case x if x == 1 => Some("error 1") }
def check2: Validator = { case x if x == 2 => Some("error 2") }
def check3: Validator = { case x if x == 3 => Some("error 3") }
def default: Validator = { case _ => None }

// we can chain together partial functions
// the same way as we did with Option type
val result = check1
  .orElse(check2)
  .orElse(check3)
  .orElse(default) {
    3 // this is an actual parameter for each defined function
  }

// the result is Option
// if there was an error we get Some(error)
// otherwise the result is Option.None in which case
// we return "passed"
println(result.getOrElse("passed"))

Here we can use normal function names as well and we got rid of else's part thanks to the design of the partial function. The only thing is that if there is a need to add another check (one more if-else block), it should be added in 2 spots: function declaration and as a new .orElse function call

Option 3:
It is easy to notice that all the above partial functions can be added in a List

type Validator = PartialFunction[Int, Option[String]]

val validations: List[Validator] = List(
  { case x if x == 1 => Some("error 1") },
  { case x if x == 2 => Some("error 2") },
  { case x if x == 3 => Some("error 3") },
  { case _ => None }
)

Then List can be traversed and .orElse function can be applied during the traversal. It should be done in any way, I chose the foldLeft function

val result = validations.tail.foldLeft(validations.head)(_.orElse(_)) {
  3
}

println(result.getOrElse("passed"))

Now if we need one more check function to add, it can be done only at one spot - another element of the List

Option 4:
Another option I wanted to share is that it is also possible to override PartialFunction trait by anonymous class and implement its 2 methods: isDefinedAt and apply

type Validator = PartialFunction[Int, Option[String]]

val check1 = new Validator {
  override def isDefinedAt(x: Int): Boolean = x == 1
  override def apply(v1: Int): Option[String] = Some("error 1")
}

val check2 = new Validator {
  override def isDefinedAt(x: Int): Boolean = x == 2
  override def apply(v1: Int): Option[String] = Some("error 2")
}

val check3 = new Validator {
  override def isDefinedAt(x: Int): Boolean = x == 3
  override def apply(v1: Int): Option[String] = Some("error 3")
}

val default = new Validator {
  override def isDefinedAt(x: Int): Boolean = true
  override def apply(v1: Int): Option[String] = None
}

Then we can chain those functions the same way we did in the 2nd option

val result = check1
  .orElse(check2)
  .orElse(check3)
  .orElse(default) {
    3
  }

println(result.getOrElse("passed"))

You can use the Option type and methods that return Option[_] in a for comprehension to chain verifications while extracting partial results. Processing stops when an option returns None

for {
    klass <- program.classes
    name <- checkName // Option[String]
    vars <- checkVars // Option[SomeType]
    methods <- checkMethods // Option[SomeOtherT]
    ctx <- addToContext // Option[...]
} {
// do something with klass
// if you got here, all the previous Options returned Some(_)
}

This depends a lot on what you want to happen on an error but this seems like a good case for chained maps using options:

def checkName(klass: Klass): Option[Klass] = if (compBoolean) Some(klass) else None
def checkVars(klass: Klass): Option[Klass] = if (compBoolean) Some(klass) else None
def checkMethods(klass: Klass): Option[Klass] = if (compBoolean) Some(klass) else None
def finalOp(klass: Klass): OutputClass = //your final operation

// Use the above checks
program.classes.map(checkName(_).flatMap(checkVars).flatMap(checkMethods).map(finalOp).getOrElse(defaultResult))

If you want to skip/omit elements which fail all of your checks then you would use a flatMap:

program.classes.flatMap(checkName(_).flatMap(checkVars).flatMap(checkMethods).map(finalOp))