Scala - If/Else Expressions

Unlike Java or C++ where if/else are statements, Scala treats them as expressions that evaluate to a value. This fundamental difference enables assigning the result directly to a variable without...

Key Insights

  • Scala’s if/else constructs are expressions that return values, not statements, enabling functional programming patterns and eliminating the need for ternary operators
  • Every if/else expression has a type determined by the unified type of all branches, with Unit as the fallback when branches have incompatible types
  • Pattern matching and guard clauses provide more powerful alternatives to nested if/else chains for complex conditional logic

If/Else as Expressions

Unlike Java or C++ where if/else are statements, Scala treats them as expressions that evaluate to a value. This fundamental difference enables assigning the result directly to a variable without temporary mutations.

val age = 25
val category = if (age >= 18) "adult" else "minor"
// category: String = "adult"

// Compare with statement-based approach (anti-pattern in Scala)
var categoryBad = ""
if (age >= 18) {
  categoryBad = "adult"
} else {
  categoryBad = "minor"
}

The expression-based approach eliminates mutable variables and makes code more concise. The compiler infers the result type from both branches, requiring them to share a common type.

Type Inference and Unified Types

When branches return different types, Scala computes the least upper bound (LUB) to determine the expression’s type.

val result = if (true) 42 else "hello"
// result: Any = 42

val numericResult = if (true) 42 else 3.14
// numericResult: Double = 42.0

val optionResult = if (false) Some(10) else None
// optionResult: Option[Int] = None

In the first example, Int and String have Any as their common supertype. In the second, Int is widened to Double. The third example shows how Some[Int] and None.type unify to Option[Int].

Omitting the Else Branch

When you omit the else branch, the expression returns Unit for the false case, which typically indicates you’re using if for side effects rather than value computation.

val result = if (age >= 18) "adult"
// result: Any = "adult"

// Equivalent to
val explicitResult = if (age >= 18) "adult" else ()
// explicitResult: Any = "adult"

The type becomes Any because it’s the LUB of String and Unit. This pattern usually signals imperative code:

if (temperature > 100) {
  println("Warning: High temperature!")
}
// Returns Unit, used for side effect only

Multi-Line Blocks

If/else expressions support multi-line blocks. The last expression in each block becomes the branch’s value.

val discount = if (isPremiumMember) {
  val baseDiscount = 0.15
  val loyaltyBonus = 0.05
  baseDiscount + loyaltyBonus
} else {
  0.10
}
// discount: Double = 0.20 (if isPremiumMember is true)

Avoid mixing side effects with value returns in the same block. Keep expression blocks pure when possible:

// Anti-pattern: mixing side effects with return value
val result = if (condition) {
  println("Debug message")  // side effect
  calculateValue()          // return value
} else {
  42
}

// Better: separate concerns
if (condition) println("Debug message")
val result = if (condition) calculateValue() else 42

Chaining If/Else Expressions

Chain multiple conditions using else-if patterns. Scala evaluates them top-to-bottom, short-circuiting on the first match.

def getGrade(score: Int): String = {
  if (score >= 90) "A"
  else if (score >= 80) "B"
  else if (score >= 70) "C"
  else if (score >= 60) "D"
  else "F"
}

val grade = getGrade(85)  // "B"

For complex conditions, pattern matching provides better readability:

def getGradeWithMatch(score: Int): String = score match {
  case s if s >= 90 => "A"
  case s if s >= 80 => "B"
  case s if s >= 70 => "C"
  case s if s >= 60 => "D"
  case _ => "F"
}

Nested If/Else Expressions

Nesting if/else expressions is valid but can harm readability. Consider refactoring deeply nested conditions.

// Nested approach (harder to read)
def calculateShipping(weight: Double, isPremium: Boolean, isInternational: Boolean): Double = {
  if (isPremium) {
    if (isInternational) {
      if (weight > 10) 50.0 else 30.0
    } else {
      0.0  // free shipping
    }
  } else {
    if (isInternational) {
      if (weight > 10) 80.0 else 50.0
    } else {
      if (weight > 10) 15.0 else 10.0
    }
  }
}

// Flattened with early returns (better)
def calculateShippingFlat(weight: Double, isPremium: Boolean, isInternational: Boolean): Double = {
  if (isPremium && !isInternational) return 0.0
  
  val baseRate = (isPremium, isInternational, weight > 10) match {
    case (true, true, true) => 50.0
    case (true, true, false) => 30.0
    case (false, true, true) => 80.0
    case (false, true, false) => 50.0
    case (false, false, true) => 15.0
    case (false, false, false) => 10.0
  }
  baseRate
}

Using If/Else with Options

Combine if/else with Option types for null-safe code. However, prefer Option’s built-in methods when possible.

val maybeUser: Option[User] = findUser(userId)

// Using if/else
val userName = if (maybeUser.isDefined) maybeUser.get.name else "Anonymous"

// Better: use map and getOrElse
val userNameBetter = maybeUser.map(_.name).getOrElse("Anonymous")

// Or fold for more control
val greeting = maybeUser.fold("Hello, Guest") { user =>
  s"Hello, ${user.name}"
}

Guard Clauses and Early Returns

Use guard clauses at function boundaries to handle invalid inputs early, reducing nesting.

def processOrder(order: Order): Either[String, Receipt] = {
  if (order.items.isEmpty) 
    return Left("Order cannot be empty")
  
  if (order.total < 0) 
    return Left("Invalid order total")
  
  if (!order.customer.isVerified) 
    return Left("Customer not verified")
  
  // Main logic without nesting
  val receipt = generateReceipt(order)
  Right(receipt)
}

While early returns work in Scala, functional style prefers composing validations:

def processOrderFunctional(order: Order): Either[String, Receipt] = {
  for {
    _ <- Either.cond(order.items.nonEmpty, (), "Order cannot be empty")
    _ <- Either.cond(order.total >= 0, (), "Invalid order total")
    _ <- Either.cond(order.customer.isVerified, (), "Customer not verified")
    receipt <- Right(generateReceipt(order))
  } yield receipt
}

Performance Considerations

If/else expressions compile to bytecode similar to Java’s conditional statements. The JVM optimizes them effectively, including branch prediction.

// These have equivalent performance
val result1 = if (x > 0) "positive" else "non-positive"
val result2 = if (x > 0) { "positive" } else { "non-positive" }

// Pattern matching may have slight overhead for simple conditions
val result3 = x match {
  case n if n > 0 => "positive"
  case _ => "non-positive"
}

For hot paths with simple conditions, if/else is optimal. Use pattern matching for clarity when handling complex data structures, not for micro-optimization.

When to Use Alternatives

Replace if/else chains when:

  • Matching on case classes or sealed traits: use pattern matching
  • Handling multiple boolean flags: use pattern matching on tuples
  • Dealing with Option/Either/Try: use map, flatMap, fold, or for-comprehensions
  • Implementing polymorphic behavior: use type classes or subtype polymorphism
// Poor: if/else on type checks
if (animal.isInstanceOf[Dog]) {
  animal.asInstanceOf[Dog].bark()
} else if (animal.isInstanceOf[Cat]) {
  animal.asInstanceOf[Cat].meow()
}

// Better: pattern matching
animal match {
  case dog: Dog => dog.bark()
  case cat: Cat => cat.meow()
  case _ => ()
}

// Best: polymorphism
animal.makeSound()

Scala’s if/else expressions provide a clean, functional approach to conditional logic. Master them alongside pattern matching and Option handling to write expressive, type-safe code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.