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.
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.
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 += 1Scala 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.
// 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 directlyNonFatal 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)
-1Before 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 cfgThe 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
whileloop or aprintlnreturnsUnit, 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 _: Throwableorcase _ =>in acatchswallowsControlThrowableand interrupts; useNonFatal. - Return in finally: a value or
returninfinallyis discarded or overrides the result in confusing ways; keepfinallyfor cleanup only, or useUsing. - 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
| Need | Prefer | Avoid |
|---|---|---|
| Pick between two values | if ... else expression | A var assigned in branches |
| Branch on shape or many cases | match on a sealed type | Chains of isInstanceOf |
| Sequence steps that may fail | for over Either or Option | Nested null checks |
| Stop at first match | find, exists, collectFirst | return inside a lambda |
| Early exit from complex logic | boundary / break (3.3+) | Breaks with catch-alls nearby |
| Hot numeric loop | while over an array | Range closures, if profiled as slow |
| Resource cleanup | Using | Hand-written finally with nested tries |
What to do next
- Turn on the compiler warnings for your Scala version that flag non-exhaustive matches and discarded non-Unit values, and fix what they find.
- Search the codebase for
returninside lambdas andcase _: Throwable; replace them with collection methods,boundaryorNonFatal. - Pick one function that uses a var assigned in branches and rewrite it as a single
iformatchexpression. - Take a nested null-check or try/catch chain and rewrite it as a
foroverEither, carrying a typed error. - Write out the desugared form of one non-trivial
forby hand to confirm you can predict its result type. - Replace manual resource cleanup with
Usingand add a test that the resource is closed when the body throws.