Scala Programming Guidesscala-basicsscala-tutorial

Control Flow — But Make It Functional | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Control Flow — But Make It Functional - Scala Programming Guide

if/else as Expressions

In most languages, if/else is a statement—it does something but returns nothing. In Scala, if/else is an expression—it evaluates to a value. This is a subtle but powerful distinction that fundamentally changes how you write code.

// Traditional languages (Java, C++)
if (age >= 18) {
  status = "adult"  // Statement: does something
} else {
  status = "minor"  // but returns nothing
}

// Scala: if/else as an expression
val status = if (age >= 18) "adult" else "minor"

// This is functionally equivalent to a ternary operator
val status = age >= 18 ? "adult" : "minor"  // Java-style

Why does this matter? It encourages you to think in terms of values rather than imperative side effects. When if/else returns a value, you naturally build computations from these values, avoiding mutable state and intermediate variables. This leads to clearer, more composable code.

Let's see a real example—imagine we're building a music recommendation system:

val userPreference = "electronic"
val artistPopularity = 87  // Out of 100

// Using if/else as an expression to compute a recommendation score
val recommendationScore = if (userPreference == "electronic") {
  if (artistPopularity > 80) 0.95 else 0.70
} else if (userPreference == "indie") {
  if (artistPopularity > 60) 0.80 else 0.50
} else {
  0.30
}

println(s"Recommend this artist with score: $recommendationScore")

// Nested if/else is still clear and readable
val message = if (recommendationScore > 0.8) {
  "Strongly recommended"
} else if (recommendationScore > 0.5) {
  "Worth exploring"
} else {
  "Skip this one"
}

println(message)

Notice: no temporary variables, no reassignments, no mutation. We compute values step by step, each flowing into the next. This is the essence of functional programming: data transformations rather than imperative mutations.

Important: Every branch of an if/else must have the same type:

val value = if (true) 42 else "text"  // ERROR: type mismatch
// One branch returns Int, the other String. Scala needs a common type.

val value = if (true) 42 else 0  // OK: both are Int
val value = if (true) "hello" else "world"  // OK: both are String

The type system enforces consistency. You can't accidentally have one branch return an Int and another return a String, even though both are valid individually. This catches bugs at compile time that would otherwise manifest as runtime errors in dynamic languages.

If a branch doesn't return anything useful, Scala uses Unit:

val result = if (user.isAdmin) {
  println("Admin access granted")
  // No explicit return, so this branch returns Unit
} else {
  println("Access denied")
  // This branch also returns Unit implicitly
}
// result is of type Unit (equivalent to void)

When you call println, it returns Unit (the result of executing a side effect). If that's the last expression in a branch, the branch returns Unit. This is why functions that only do side effects, like logging, have return type Unit.

match Expressions: Pattern Matching

Pattern matching is one of Scala's best features. It's like a switch statement on steroids—you don't just match values, you match shapes of data. You can extract data from complex structures, check conditions, and branch all in one elegant construct.

val genre = "synthpop"

// Basic match expression
val description = genre match {
  case "synthpop" => "Electronic and pop fusion"
  case "synthwave" => "80s-inspired digital soundscapes"
  case "vaporwave" => "Surreal, nostalgic electronic aesthetic"
  case _ => "Unknown genre"  // Default case (underscore catches anything)
}

println(description)
// "Electronic and pop fusion"

// Match expressions return values
val emoji = genre match {
  case "synthpop" => "🎹"
  case "synthwave" => "🌃"
  case "vaporwave" => "🎧"
  case _ => "🎵"
}

At first glance, this looks like a switch statement. But pattern matching really shines with more complex data:

case class Artist(name: String, yearsActive: Int, genreCount: Int)

val artist = Artist("The Midnight", 12, 1)

val assessment = artist match {
  // Match specific values
  case Artist("The Midnight", _, _) =>
    "They're incredible!"

  // Capture values with variable names
  case Artist(name, years, genres) if years > 20 =>
    s"$name is a veteran (${years} years active)"

  // With guards (additional conditions)
  case Artist(name, years, _) if years < 5 =>
    s"$name is an emerging artist"

  // Default case
  case _ =>
    "Interesting artist"
}

println(assessment)
// "They're incredible!"

The first case matches specifically on the artist's name. The second case captures the values and also applies a guard condition (if years > 20). The third is similar. Each case can have its own logic for destructuring and filtering.

We'll dive much deeper into pattern matching in Chapter 8, including advanced techniques like matching on types, sequences, and nested structures. But the key insight now is: match replaces if/else when you're checking many cases or destructuring data. It's more powerful and often more readable.

for Loops: Imperative Style

Sometimes you need to iterate. Scala has for loops for imperative iteration:

val playlist = List("Track 1", "Track 2", "Track 3", "Track 4")

// Basic for loop (note: no indexing—we iterate directly over values)
for (track <- playlist) {
  println(track)
}
// Prints:
// Track 1
// Track 2
// Track 3
// Track 4

// Loop over a range
for (i <- 1 to 5) {  // 1 to 5 inclusive
  println(s"Song $i")
}

for (i <- 1 until 5) {  // 1 to 4 (until excludes the end)
  println(s"Song $i")
}

// Multiple generators (nested loops)
val genres = List("synthwave", "synthpop")
val years = List(2015, 2020)

for (genre <- genres; year <- years) {
  println(s"$genre in $year")
}
// Prints:
// synthwave in 2015
// synthwave in 2020
// synthpop in 2015
// synthpop in 2020

// Filter with if (guard)
for (track <- playlist if track.contains("2")) {
  println(track)
}
// Prints only "Track 2"

// Multiple conditions
for (i <- 1 to 10; if i % 2 == 0; if i > 4) {
  println(i)  // 6, 8, 10
}

The for loop syntax item <- collection reads naturally: "for each item in the collection." The <- is used throughout Scala for iteration and comprehension, and it always means "pull from" or "iterate over."

But: Traditional for loops are imperative and less composable. Most of the time, you'll use for-yield instead. The for loop without yield is useful for side effects (like printing or I/O), but for transforming data, for-yield is almost always better.

for-yield: Functional Iteration

for-yield transforms a collection. It's like map in functional programming, but with a syntax that's often more readable for complex transformations:

val numbers = List(1, 2, 3, 4, 5)

// Traditional approach (mutating a collection)
var result = scala.collection.mutable.ListBuffer[Int]()
for (n <- numbers) {
  result += n * 2
}
val doubled = result.toList  // Convert buffer to immutable list

// Scala way: for-yield
val doubled = for (n <- numbers) yield n * 2
// doubled is List(2, 4, 6, 8, 10)

// for-yield creates a new collection—doesn't mutate anything
val lengths = for (track <- playlist) yield track.length
// lengths is List(7, 7, 7, 7) — lengths of each track name

// With guards
val evenNumbers = for (n <- numbers if n % 2 == 0) yield n
// evenNumbers is List(2, 4)

// Multiple generators and destructuring
val pairs = List((1, "a"), (2, "b"), (3, "c"))
val extracted = for ((num, letter) <- pairs) yield s"$num:$letter"
// extracted is List("1:a", "2:b", "3:c")

// Complex transformations
val artists = List(
  (name = "M83", yearsActive = 15),
  (name = "Carpenter Brut", yearsActive = 10),
  (name = "The Midnight", yearsActive = 12)
)

val reports = for ((name, years) <- artists) yield {
  s"$name has been active for $years years"
}
// reports is List("M83 has been active for 15 years", ...)

for-yield is syntactic sugar for functional operations. Under the hood, it calls .map() and other collection methods. We'll explore the functional versions in Chapter 4, but for-yield is often more readable for complex transformations, especially when you have multiple generators and filters.

Think of for-yield as "for each item in the collection, yield a transformed value." The result is always a new collection of the same type as the input (mostly).

Key insight: for-yield is syntactic sugar. Under the hood, it calls .map() and other collection methods. We'll explore the functional versions in Chapter 4, but for-yield is often more readable for complex transformations.

while and do-while: Use Sparingly

Scala has while and do-while loops. They work like Java, but you'll rarely use them in well-written Scala:

// while loop (check condition before executing)
var count = 0
while (count < 5) {
  println(s"Count is $count")
  count += 1
}

// do-while loop (execute once, then check condition)
var attemptCount = 0
do {
  println("Attempting connection...")
  attemptCount += 1
} while (attemptCount < 3)

Why use for/for-yield instead? They're immutable—they don't require a mutable counter. They compose better with the rest of the functional ecosystem. They express intent more clearly: "iterate over this collection" rather than "loop until this counter reaches a value." With while loops, you're managing state manually, incrementing counters, updating accumulators. With for, all that bookkeeping is hidden.

Rule: If you're using while, ask yourself: "Could I use a collection method or for-yield instead?" Usually, the answer is yes. If you're iterating over a collection, use for or a collection method. If you're waiting for a condition (like a network retry), while might be appropriate, but often even then, a better abstraction exists.

No break/continue: Alternative Patterns

Java has break and continue keywords. Scala doesn't—and that's intentional. They encourage imperative thinking and complicate control flow analysis. Instead, Scala provides functional alternatives that are often more elegant.

Instead of break:

// Avoid this (imperative style with break)
// Java: for (int i = 0; i < list.size(); i++) { if (found) break; }

// Use `find()` or `takeWhile()` instead
val items = List(1, 5, 2, 8, 3, 9)

// Find the first item greater than 5
val result = items.find(_ > 5)  // Option(8)

// Take items until you hit one greater than 5
val taken = items.takeWhile(_ <= 5)  // List(1, 5, 2)

find() returns an Option—either Some(value) if found, or None if not found. takeWhile() returns all items from the start until the condition becomes false. Both express the intent more clearly than a break statement.

Instead of continue:

// Avoid this (imperative style with continue)
// Java: for (item in items) { if (skip) continue; ... process(item); }

// Use `filter()` instead
val items = List(1, 2, 3, 4, 5, 6)

// Skip even numbers and process odd ones
val processed = items
  .filter(_ % 2 == 1)  // Keep only odd
  .map(item => item * 10)  // Transform
  .foreach(println)

// Or use for-yield with guards
val results = for (item <- items if item % 2 == 1) yield item * 10

With filter(), you explicitly state which items to keep. The code is a sequence of transformations, each clear and composable. This is far more readable than a loop with scattered continue statements.

When you must exit early:

// Use return if you absolutely must exit a function early
def findUser(id: Int): String = {
  if (id < 0) return "Invalid ID"

  // ... more logic ...

  "User found"
}

// But usually, use if/else or match to structure logic
def findUser(id: Int): String =
  if (id < 0) "Invalid ID"
  else "User found"

The second version is cleaner. Structure your code so the logic flows naturally without requiring early exits. This usually means organizing into nested if/else or match expressions that handle all cases explicitly.

The philosophy: Structure your code so you don't need these shortcuts. Functional programming naturally avoids the scenarios where break and continue seem necessary. When you stop thinking in terms of imperative loops with flow control, you start thinking in terms of data transformations, and the need for break/continue simply vanishes.