Most languages give you statements that do things and expressions that compute values, and control flow lives on the statement side. Scala removes that line. if, match, try and blocks are all expressions with a value and a type, and for is syntax that the compiler rewrites into ordinary method calls. Once that clicks, a lot of Scala style makes sense: fewer mutable variables, fewer early returns, and code where each branch says what it produces.

This article goes through each construct, shows both Scala 2 and Scala 3 syntax where they differ, explains the typing rules that cause surprising compiler errors, covers early exit, which changed significantly in Scala 3, and ends with a worked refactoring and a list of pitfalls. Pattern matching on data types is covered in depth in case classes and sealed traits and ADTs; here the focus is on match as a control-flow tool.

Advertisement

Everything is an expression

A block's value is the value of its last expression. An if with both branches has the type that is the least upper bound of the two branch types. That is why you rarely need a variable declared and then assigned in branches:

// Scala 2 syntax, also valid in Scala 3
val fee = if (amount > 1000) amount * 0.01 else 5.0

// Scala 3 optional braceless syntax
val fee3 =
  if amount > 1000 then amount * 0.01
  else 5.0

// A block is an expression: its value is the last line
val normalised = {
  val trimmed = raw.trim
  if (trimmed.isEmpty) "unknown" else trimmed.toLowerCase
}

The typing rule has consequences. If one branch returns Int and the other String, the inferred result is a broad common supertype such as Any (Scala 3 keeps the union Int | String only when you write it as the expected type), which compiles and then fails later where you needed a number. An if without else has an implicit else (), so its type mixes in Unit: if (ok) 42 has type AnyVal, not Int. When the compiler says it found AnyVal or Any where you expected something specific, look for a missing else or a branch returning the wrong type. Annotating the expected type, as in val fee: Double = ..., turns the confusing error at the use site into a clear one at the definition.

match as a control-flow tool

match is a generalised switch that also returns a value. Cases are tried top to bottom; each can bind variables, test types, destructure values and add a guard. If nothing matches, a MatchError is thrown at run time, so for sealed types the compiler checks exhaustiveness and warns about missing cases.

def classify(code: Int, body: String): String = code match {
  case 200 | 204                   => "ok"
  case c if c >= 300 && c < 400    => "redirect"
  case 429                         => "throttled"
  case c if c >= 500               => s"server error $c"
  case _ if body.contains("quota") => "quota"
  case other                       => s"client error $other"
}

// Scala 3: braces optional, same semantics
def sign(n: Int): String = n match
  case 0          => "zero"
  case x if x > 0 => "positive"
  case _          => "negative"

Three details matter. Guards defeat exhaustiveness checking, because the compiler cannot reason about arbitrary conditions, so end a guarded match with a catch-all. A lower-case name in a pattern is a new binding, not a comparison with an existing value: case limit => matches everything. Compare with a stable identifier using back-ticks, case `limit` =>, or a capitalised constant. And match on literal integers can compile to a JVM table switch; the @switch annotation asks the compiler to warn if it cannot.

Advertisement

Loops: while, and the missing do-while

while is the one loop construct that is not an expression with a useful value; it returns Unit and exists for imperative code and hot paths. Scala 3 adds a braceless form with do.

// Scala 2
var i = 0
var sum = 0L
while (i < arr.length) { sum += arr(i); i += 1 }

// Scala 3
var j = 0
while j < arr.length do
  sum += arr(j)
  j += 1

Scala 3 dropped do { ... } while (cond), because do now introduces a loop body. The same behaviour, body first and condition after, is written by putting the body inside the condition block, since a block's value is its last expression:

// Scala 3 replacement for do-while: run the body, then test
var attempts = 0
while
  attempts += 1
  val connected = tryConnect()
  !connected && attempts < 5
do ()

In idiomatic code most loops become collection methods (map, foldLeft, exists) or tail-recursive functions annotated with @tailrec, which the compiler turns into a jump and rejects if the call is not in tail position. Reach for while when a profiler shows the functional version is the bottleneck, typically tight numeric loops over arrays.

for-comprehensions are method calls

A for expression is rewritten by the compiler before type checking. Each generator becomes flatMap, except the last, which becomes map; each guard becomes withFilter; a for without yield becomes foreach and returns Unit.

How a for-comprehension desugars: every arrow becomes a method callforx <- xs ; if x > 0 ; y <- f(x)yield x + ycompilermethod callsxs.withFilter(x => x > 0).flatMap(x => f(x).map(y => x + y))Generator a <- eflatMap, or map if lastGuard if condwithFilterNo yieldforeach, result is UnitAny type with these methods works: List, Option, Either, Future, IO, your own types.The container type of the first generator decides what the whole expression returns.
Generators, guards and yield map one-to-one onto flatMap, withFilter, map and foreach calls.
// What you write
val pairs = for {
  x <- List(1, 2, 3)
  if x % 2 == 1
  y <- List("a", "b")
} yield s"$x$y"

// Roughly what the compiler produces
val pairs2 = List(1, 2, 3)
  .withFilter(x => x % 2 == 1)
  .flatMap(x => List("a", "b").map(y => s"$x$y"))
// List(1a, 1b, 3a, 3b)

Because it is just method calls, for works on any type that has these methods. Over Option it short-circuits on the first None; over Either it stops at the first Left, which makes it the standard way to sequence validations (see Either); over Future or an effect type it sequences asynchronous steps. Two rules follow. The type of the first generator decides the result type, so you cannot freely mix Option and List generators without converting. And a numeric for (i <- 0 until n) allocates a Range and calls a closure per element; the JIT usually makes that cheap, but it is not a raw loop.

try, throw and resources

try is an expression too: its value is the value of the try block or of the matching catch case. The finally block runs for side effects only; its value is discarded. throw is an expression of type Nothing, the type with no values, which is a subtype of every type, so a throwing branch never widens the type of an if or match.

import scala.util.control.NonFatal
import scala.util.Using
import scala.io.Source

def port(s: String): Int =
  try s.toInt
  catch { case _: NumberFormatException => 8080 }

def require(cond: Boolean, msg: String): Unit =
  if (!cond) throw new IllegalArgumentException(msg)   // Nothing fits Unit

// Close the resource on every path, success or failure (Scala 2.13+)
def firstLine(path: String): scala.util.Try[String] =
  Using(Source.fromFile(path))(src => src.getLines().next())

def safely[A](op: => A): Option[A] =
  try Some(op)
  catch { case NonFatal(e) => None }   // never catch Throwable directly

NonFatal deliberately does not match VirtualMachineError, InterruptedException, LinkageError or Scala's ControlThrowable, which some control-flow mechanisms use internally. A bare case e: Throwable swallows all of those, which breaks thread interruption and exception-based early exit. For expected failures, returning Try, Either or Option is usually better than throwing; functional error handling covers the trade-offs.

Early exit: return, Breaks and boundary

return exits the enclosing method, and inside a method body it works the way you expect. The trouble is return inside a lambda. A lambda is a separate function object, so returning from the enclosing method means unwinding through the collection method that called the lambda, which Scala implemented by throwing an exception and catching it at the method boundary. That is slow, surprising, and broken by any catch-all handler in between. The Scala 3 reference states that returning from nested anonymous functions is deprecated since Scala 3.2.0.

Scala 2's library alternative, scala.util.control.Breaks, also uses an exception and has the same catch-all hazard. Scala 3.3.0 added scala.util.boundary and boundary.break: a boundary block delimits a region, and break(value) anywhere inside it, including inside lambdas, exits the region with that value. It is type checked, and breaks within the same method can be compiled to jumps; a break that crosses a lambda is still an exception underneath, and that exception extends RuntimeException, so a catch NonFatal between the break and its boundary will swallow it. This is the example from the Scala 3 reference:

import scala.util.boundary, boundary.break

def firstIndex[T](xs: List[T], elem: T): Int =
  boundary:
    for (x, i) <- xs.zipWithIndex do
      if x == elem then break(i)
    -1

Before reaching for any of these, check whether a collection method already expresses the early exit: find, exists, forall, indexWhere, takeWhile and collectFirst all stop at the first decisive element. On a lazy collection or an iterator, chains such as view.map(f).find(p) compute only what they need; lazy evaluation explains why.

Worked example: from imperative to expression style

A config loader reads key=value lines, skips blanks and comments, fails on the first malformed line, and requires a port between 1 and 65535. Written the way many people first write it:

def load(lines: List[String]): Map[String, String] = {
  var result = Map.empty[String, String]
  for (line <- lines) {
    val t = line.trim
    if (t.nonEmpty && !t.startsWith("#")) {
      val idx = t.indexOf('=')
      if (idx < 0) return null                 // non-local return from a lambda
      result += t.take(idx).trim -> t.drop(idx + 1).trim
    }
  }
  if (!result.contains("port")) return null
  result
}

It has a non-local return inside the for body, which is a lambda passed to foreach, a mutable accumulator, null as an error signal that tells the caller nothing, and no port range check. An expression-oriented version makes every outcome a value:

final case class ConfigError(line: Int, reason: String)

def parseLine(n: Int, raw: String): Either[ConfigError, Option[(String, String)]] =
  raw.trim match {
    case t if t.isEmpty || t.startsWith("#") => Right(None)
    case t => t.indexOf('=') match {
      case -1  => Left(ConfigError(n, s"missing '=' in: $t"))
      case idx => Right(Some(t.take(idx).trim -> t.drop(idx + 1).trim))
    }
  }

def load(lines: List[String]): Either[ConfigError, Map[String, String]] =
  for {
    pairs <- lines.zipWithIndex
               .foldLeft[Either[ConfigError, List[(String, String)]]](Right(Nil)) {
                 case (acc, (raw, i)) =>
                   for { xs <- acc; p <- parseLine(i + 1, raw) } yield xs ++ p
               }
    cfg   = pairs.toMap
    port <- cfg.get("port").toRight(ConfigError(0, "port is required"))
    n    <- port.toIntOption.filter(p => p >= 1 && p <= 65535)
              .toRight(ConfigError(0, s"bad port: $port"))
  } yield cfg

The fold stops adding entries after the first Left, although it still walks the remaining lines; for very large inputs, a boundary or a tail-recursive loop gives a true early stop. Each failure carries a line number and reason, and the type signature tells callers that loading can fail. The cfg = ... line is a value definition inside a for, which desugars to a map that carries the value forward. The final binding n is unused except as a check, which is a common and acceptable use of a generator.

Pitfalls

  • The silent Unit: a method whose last expression is an assignment, a while loop or a println returns Unit, whatever its name suggests. Annotate public method return types.
  • Procedure syntax: Scala 2's def run() { ... } without = is deprecated and dropped in Scala 3; always write : Unit =.
  • Catch-all handlers: case _: Throwable or case _ => in a catch swallows ControlThrowable and interrupts; use NonFatal.
  • Return in finally: a value or return in finally is discarded or overrides the result in confusing ways; keep finally for cleanup only, or use Using.
  • Accidental binding in patterns: a lower-case identifier in a case matches anything; use back-ticks for comparisons.
  • Unchecked MatchError: matching on a non-sealed type with no default compiles and fails at run time.
  • Side effects in for over a lazy type: over a view or LazyList, side effects may run later or not at all.

Choosing a construct

NeedPreferAvoid
Pick between two valuesif ... else expressionA var assigned in branches
Branch on shape or many casesmatch on a sealed typeChains of isInstanceOf
Sequence steps that may failfor over Either or OptionNested null checks
Stop at first matchfind, exists, collectFirstreturn inside a lambda
Early exit from complex logicboundary / break (3.3+)Breaks with catch-alls nearby
Hot numeric loopwhile over an arrayRange closures, if profiled as slow
Resource cleanupUsingHand-written finally with nested tries

What to do next

  1. Turn on the compiler warnings for your Scala version that flag non-exhaustive matches and discarded non-Unit values, and fix what they find.
  2. Search the codebase for return inside lambdas and case _: Throwable; replace them with collection methods, boundary or NonFatal.
  3. Pick one function that uses a var assigned in branches and rewrite it as a single if or match expression.
  4. Take a nested null-check or try/catch chain and rewrite it as a for over Either, carrying a typed error.
  5. Write out the desugared form of one non-trivial for by hand to confirm you can predict its result type.
  6. Replace manual resource cleanup with Using and add a test that the resource is closed when the body throws.
Key takeaway: In Scala, if, match, try and blocks are expressions with types, so prefer computing a value in one expression over assigning variables in branches, and watch for Unit or Any creeping in from a missing else. while is the imperative loop; Scala 3 dropped do-while. for-comprehensions desugar to flatMap, map, withFilter and foreach, so they sequence any type with those methods, from List to Either to effect types. throw has type Nothing, NonFatal is the safe catch outside a boundary region, and Using closes resources. Return from inside lambdas is deprecated since Scala 3.2.0; use collection methods first and boundary/break, added in 3.3.0, when you need a real early exit.