Exceptions, boundary & Early Exit
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, 2026-10-12) and every example was run with Scala 3.10.0 through Scala CLI
on JDK 26 (checked on 2026-10-15). Scala handles exceptions with the JVM mechanism, but wraps it in an expression-oriented
syntax: try yields a value, catch is a pattern match, throw has type Nothing, and nothing is checked at compile
time. Early exit, which Java handles with return, break and continue, is done in Scala with
scala.util.boundary and boundary.break, available since Scala 3.3 (and therefore on the 3.3 LTS and the 3.9 LTS).
The experimental checked-exceptions feature (saferExceptions) is covered at the end. For the Java baseline see
Java Exceptions; for the Groovy treatment see
Groovy Exceptions & Error Handling. Errors as values
(Option, Either, Try) live on Either & Try.
try, catch and finally
try is an expression: its value is the value of the try block, or of the catch case that handled the exception. The
finally block runs for its side effects and never changes that value. The catch clause is a pattern match on the
thrown Throwable, so it accepts everything match does: type patterns, alternatives, binders with @, extractors and
guards. The first matching case wins; if no case matches, the exception propagates.
The following parser belongs to the trailhead scenario. It turns a line of a trail file into a Trail and wraps any
low-level failure in a domain exception, keeping the original as cause.
//> using scala 3.10.0
import scala.util.control.NonFatal
enum Difficulty:
case Easy, Moderate, Hard
case Alpine(grade: Int)
case class Trail(name: String, lengthKm: Double, ascentM: Int, difficulty: Difficulty)
class TrailParseException(val line: String, cause: Throwable)
extends Exception(s"cannot parse '$line'", cause)
def parseTrail(line: String): Trail =
try
val Array(name, km, up, level) = line.split(',').map(_.trim)
val difficulty = level match
case "easy" => Difficulty.Easy
case "hard" => Difficulty.Hard
case s"alpine-$grade" => Difficulty.Alpine(grade.toInt)
case other => throw IllegalArgumentException(s"unknown level $other")
Trail(name, km.toDouble, up.toInt, difficulty)
catch
case e: MatchError => throw TrailParseException(line, e)
case e @ (_: NumberFormatException | _: IllegalArgumentException) =>
throw TrailParseException(line, e)
@main def parse(): Unit =
val lines = List("Pico, 12.5, 900, alpine-3", "Lago, 4, 120, easy", "Broken, x, 1, easy",
"Short, 1", "Odd, 1, 2, steep")
for line <- lines do
val msg =
try parseTrail(line).toString
catch
case e: TrailParseException => s"${e.getMessage} <- ${e.getCause.getClass.getSimpleName}"
finally println(s"[done] $line")
println(msg)
[done] Pico, 12.5, 900, alpine-3
Trail(Pico,12.5,900,Alpine(3))
[done] Lago, 4, 120, easy
Trail(Lago,4.0,120,Easy)
[done] Broken, x, 1, easy
cannot parse 'Broken, x, 1, easy' <- NumberFormatException
[done] Short, 1
cannot parse 'Short, 1' <- MatchError
[done] Odd, 1, 2, steep
cannot parse 'Odd, 1, 2, steep' <- IllegalArgumentException
Three details are worth reading twice. The failed destructuring val Array(name, km, up, level) = ... on a two-element
array throws a MatchError at run time (the language does not know the array length). The alternative pattern
_: NumberFormatException | _: IllegalArgumentException binds both under one name e with @; NumberFormatException is
a subclass of IllegalArgumentException, so the first alternative is redundant, which is harmless. And finally
printed after the catch produced its value: it runs last, but it does not return anything.
A single-line try and a try that is a value
When the body is short, try, catch and finally fit on one line, and the whole construct can initialise a val.
Because catch takes cases, the single case can use the compact catch case ... form (no braces, no match).
//> using scala 3.10.0
@main def tryValue(): Unit =
val n: Int = try "42".toInt catch case _: NumberFormatException => 0 finally println("cleanup")
println(n)
val bad = try { 1 / 0 } catch { case _: ArithmeticException => -1 } // Scala 2 braces still compile
println(bad)
cleanup
42
-1
If the type of the try block and the catch cases differ, the result is their least upper bound, exactly like the
branches of an if. A case that ends in throw has type Nothing, so it never widens the result.
Guards, NonFatal and catching too much
A catch case can have a guard, and scala.util.control.NonFatal is an extractor that matches every Throwable except
the ones that should not normally be handled: VirtualMachineError (which includes OutOfMemoryError and
StackOverflowError), ThreadDeath, InterruptedException, LinkageError and scala.util.control.ControlThrowable.
//> using scala 3.10.0
import scala.util.control.NonFatal
def risky(i: Int): Int = i match
case 0 => throw IllegalStateException("zero")
case 1 => throw OutOfMemoryError("fake")
case _ => i
@main def nonFatal(): Unit =
for i <- 0 to 2 do
try println(risky(i))
catch
case e: IllegalStateException if e.getMessage == "zero" => println("guarded catch")
case NonFatal(e) => println(s"nonfatal $e")
case e: Throwable => println(s"fatal, not matched by NonFatal: $e")
guarded catch
fatal, not matched by NonFatal: java.lang.OutOfMemoryError: fake
2
Prefer case NonFatal(e) over case e: Throwable or case _. A bare catch case _ => is legal and catches
every Throwable, including errors the program cannot recover from; the compiler does not warn about it.
Source: Scala API: NonFatal
throw, Nothing and unchecked exceptions
throw is an expression of type Nothing, the bottom type that is a subtype of every other type. That is why a branch
that throws can sit next to branches that return a String without breaking the type of the whole if, and why a helper
that only fails is declared Nothing (callers then know the code after the call is unreachable).
Scala has no checked exceptions. A method may throw any Throwable without declaring it, and a caller is never forced
to catch. This differs from Java, where checked exceptions are part of the method signature and the compiler rejects an
unhandled one. Kotlin made the same choice as Scala. The annotation @throws exists only for Java: it adds a throws
clause to the bytecode signature so that Java code can use try/catch for a checked exception.
//> using scala 3.10.0
import scala.util.Using
def fail(msg: String): Nothing = throw IllegalStateException(msg)
def pick(n: Int): String =
if n > 0 then "positive"
else if n == 0 then "zero"
else fail("negative") // Nothing conforms to String
@throws[java.io.IOException]
def readHut(id: Int): String =
if id < 0 then throw java.io.IOException("no such hut") else s"hut-$id"
@main def unchecked(): Unit =
println(pick(1))
try pick(-1) catch case e: IllegalStateException => println(e.getMessage)
println(readHut(3)) // no try/catch required, nothing is checked
println(try readHut(-1) catch case _: java.io.IOException => "fallback")
positive
negative
hut-3
fallback
Compiling a Scala object with and without the annotation and inspecting it with javap shows the difference Java sees:
public static java.lang.String read(int) throws java.io.IOException;
public static java.lang.String readPlain(int);
Here read is the method annotated with @throws[java.io.IOException] and readPlain is the same method without it. Without
the annotation, Java code that catches IOException around readPlain is rejected by javac with "exception
IOException is never thrown in body of corresponding try statement". See
Calling Scala from Java.
Managing resources with scala.util.Using
Scala has no try-with-resources statement. The library object scala.util.Using fills the gap: it opens a resource
(anything with close(), via the Releasable type class, which has an instance for java.lang.AutoCloseable), runs a body,
and always closes the resource in reverse order of opening. Using(resource)(body) wraps the outcome in a Try;
Using.resource returns the value and rethrows; Using.Manager registers any number of resources through a use
function. If the body and close() both throw, the body’s exception wins and the other is added as suppressed.
//> using scala 3.10.0
import scala.util.Using
class Lock(name: String) extends AutoCloseable:
println(s"open $name")
def close(): Unit = println(s"close $name")
@main def resources(): Unit =
val r = Using(Lock("a")) { _ => Using.resource(Lock("b")) { _ => "work" } }
println(r)
val r2 = Using(Lock("c")) { _ => throw RuntimeException("boom") }
println(r2)
val r3 = Using.Manager { use =>
val a = use(Lock("m1"))
val b = use(Lock("m2"))
"manager"
}
println(r3)
open a
open b
close b
close a
Success(work)
open c
close c
Failure(java.lang.RuntimeException: boom)
open m1
open m2
close m2
close m1
Success(manager)
Using catches only NonFatal exceptions (fatal ones propagate) and is available from Scala 2.13, so the same code
compiles on Scala 2.13 and 3. The Java form is try (var l = new Lock("a")) { ... }; Kotlin uses use { }.
Source: Scala API: Using
Early exit with boundary and break
Scala 3 has no break, continue or return-from-the-middle statements for loops, because loops are method calls
with lambdas (foreach, for ... do). To stop early, Scala 3.3 added scala.util.boundary in the standard library:
-
boundary[T]: bodyrunsbodyand returns its value; it defines a label (aboundary.Label[T]given instance) in scope. -
boundary.break(value)unwinds to the nearest enclosingboundarywhose type acceptsvalue, and makes thatboundaryreturnvalue.
break needs a Label[T] given instance, so it can only be used inside a boundary, and the compiler checks that.
Behind the scenes break throws a boundary.Break exception, which carries no stack trace (it is created without
filling in the trace), so it is cheap.
//> using scala 3.10.0
import scala.util.boundary, boundary.break
case class Segment(name: String, ascent: Int)
def firstSteep(segments: List[Segment], limit: Int): Option[Segment] =
boundary:
for s <- segments do
if s.ascent > limit then break(Some(s))
None
def sumOrStop(xs: List[String]): Either[String, Int] =
boundary:
val nums = xs.map: x =>
x.toIntOption.getOrElse(break(Left(s"not a number: $x")))
Right(nums.sum)
@main def early(): Unit =
val segs = List(Segment("a", 100), Segment("b", 700), Segment("c", 900))
println(firstSteep(segs, 500))
println(firstSteep(segs, 1000))
println(sumOrStop(List("1", "2", "3")))
println(sumOrStop(List("1", "x", "3")))
Some(Segment(b,700))
None
Right(6)
Left(not a number: x)
In sumOrStop, break is called inside the lambda passed to map, several call frames below the boundary: that is
what makes it a replacement for non-local return. The value of the boundary block is the value of its last expression
when nothing breaks, so the two exits must have the same type (here Either[String, Int]).
Outside any boundary, the compiler refuses the call:
[error] ./a5.scala:5:11
[error] No given instance of type scala.util.boundary.Label[Int] was found for parameter label of method break in object boundary
[error] break(1)
[error] ^
Nested boundaries and labels
Because the label is a contextual parameter, the type of the value decides which boundary receives it. With two nested
boundaries of different types, break("x") skips the inner boundary[Int], whose label does not accept a String,
and exits the outer one. When two boundaries have the same type, break goes to the innermost one; to reach an outer
one, name the label and pass it explicitly: boundary[T] { (l: boundary.Label[T]) ?=> ... break(v)(using l) }.
//> using scala 3.10.0
import scala.util.boundary, boundary.break
@main def nested(): Unit =
val r = boundary[String]:
boundary[Int]:
break("x")
"after inner"
println(r)
x
Source: Scala API: scala.util.boundary
Pitfalls
boundary is implemented with an exception, so a catch-all handler between break and its boundary can swallow it.
Unlike its Scala 2 relative scala.util.control.Breaks, boundary.Break does not extend ControlThrowable, which means
NonFatal(e) is true for it:
//> using scala 3.10.0
import scala.util.boundary, boundary.break
import scala.util.control.NonFatal
def firstEven(xs: List[Int]): Option[Int] =
boundary:
try
for x <- xs do if x % 2 == 0 then break(Some(x))
None
catch
case NonFatal(e) => println(s"swallowed ${e.getClass.getName}"); None
@main def swallowed(): Unit = println(firstEven(List(1, 3, 4, 5)))
swallowed scala.util.boundary$Break
None
The element 4 was found, yet the result is None. Keep try/catch out of the code between a break and its boundary, or
rethrow boundary.Break first. A second pitfall is threads: break inside a Future body throws on another thread, so it
does not reach the boundary, which has already returned (the call compiles, and the failure ends up in the Future).
Use break only on the thread that opened the boundary.
Scala 2 relatives
Scala 2 (and still the 2.13 library) offers scala.util.control.Breaks with breakable and break():
//> using scala 3.10.0
import scala.util.control.Breaks.*
@main def breaks(): Unit =
breakable:
for i <- 1 to 10 do
if i == 4 then break()
print(i)
println()
123
It still compiles on 3.10.0, but the reference recommends boundary instead. Breaks throws a ControlThrowable so
NonFatal lets it through; boundary is type-safe, takes a result value, and its Label is checked by the compiler.
Non-local return is deprecated
In Scala 2, return inside a lambda returned from the enclosing method. It was implemented by throwing and catching
scala.runtime.NonLocalReturnException, which has a hidden cost and can be intercepted by a catch-all handler. It is
deprecated since Scala 3.2.0 and still compiles on 3.10.0, with a warning:
//> using scala 3.10.0
def find(xs: List[Int], p: Int => Boolean): Option[Int] =
xs.foreach: x =>
if p(x) then return Some(x)
None
@main def nonLocal(): Unit = println(find(List(1, 2, 3), _ > 1))
[warn] ./a4.scala:4:18
[warn] Non local returns are no longer supported; use `boundary` and `boundary.break` in `scala.util` instead
[warn] if p(x) then return Some(x)
[warn] ^^^^^^^^^^^^^^
Some(2)
return directly in a method body (not inside a lambda) is still fine and not deprecated, but idiomatic Scala rarely needs it
because the last expression is the result. Replace non-local returns with boundary: around the method body and break(value)
where return value was. Java code that was ported to Scala 2 with return inside forEach lambdas is the usual source.
Custom control structures with by-name parameters
A by-name parameter (body: => T) is re-evaluated at each use, which lets a method behave like a statement. Combined with
a final parameter list taken with : (a colon followed by an indented block, no braces), such methods read like built-in
syntax. The trailhead example below retries a flaky call, times a block and defines while as a function:
//> using scala 3.10.0
import scala.util.boundary, boundary.{Label, break}
import scala.util.control.NonFatal
def retry[T](times: Int)(body: => T): T =
var attempt = 1
var result: Option[T] = None
while result.isEmpty do
try result = Some(body)
catch
case NonFatal(e) if attempt < times =>
println(s"attempt $attempt failed: ${e.getMessage}")
attempt += 1
result.get
def timed[T](label: String)(body: => T): T =
try body finally println(s"$label finished")
def whileLoop(cond: => Boolean)(body: => Unit): Unit =
if cond then
body
whileLoop(cond)(body)
// a loop whose body can break out, through a context function parameter
def loopUntil(n: Int)(body: Label[Unit] ?=> Int => Unit): Unit =
boundary:
for i <- 0 until n do body(i)
@main def custom(): Unit =
var calls = 0
val v = retry(3):
calls += 1
if calls < 3 then throw RuntimeException(s"flaky $calls")
"ok after " + calls
println(v)
println(timed("sum")(1 + 2))
var i = 0
whileLoop(i < 3):
print(i); i += 1
println()
loopUntil(10): i =>
if i == 3 then break()
print(i)
println()
attempt 1 failed: flaky 1
attempt 2 failed: flaky 2
ok after 3
sum finished
3
012
012
The last helper is the pattern to remember: a parameter of type Label[Unit] ?=> Int => Unit is a context function
(see Context Functions), so the caller’s lambda receives the
label implicitly and can call break() without any boilerplate. The recursion in whileLoop is not stack-safe for long
loops (the call is not in tail position through by-name parameters); use while itself for real loops. Kotlin offers the
same style through inline functions with lambdas (and non-local return that is allowed there); Java has no equivalent.
Design by contract: require, assert, assume and ensuring
Four members of Predef express preconditions and postconditions. They throw different exceptions, which tells the reader
whose fault a failure is:
| Member | Throws | Meant for |
|---|---|---|
|
|
Preconditions on arguments: the caller is wrong. |
|
|
Internal invariants: this code is wrong. |
|
|
Facts the code relies on without checking them (a verifier may take them as given). |
|
|
Postconditions: checks the result and returns it, so it chains. |
The message argument is by-name, so it is only built when the check fails.
//> using scala 3.10.0
def planHike(days: Int, kmPerDay: Double): Double =
require(days > 0, s"days must be positive, got $days")
val total = days * kmPerDay
assert(total >= 0, "total negative")
total.ensuring(_ < 100, "too long")
def check(a: => Any): Unit =
try println(a) catch case e: Throwable => println(s"${e.getClass.getName}: ${e.getMessage}")
@main def contracts(): Unit =
check(planHike(2, 10))
check(planHike(0, 10))
check(planHike(20, 10))
check(assert(1 > 2))
check(assume(1 > 2, "assumed"))
check(require(false))
20.0
java.lang.IllegalArgumentException: requirement failed: days must be positive, got 0
java.lang.AssertionError: assertion failed: too long
java.lang.AssertionError: assertion failed
java.lang.AssertionError: assumption failed: assumed
java.lang.IllegalArgumentException: requirement failed
Scala 2 allowed switching assertions off with @elidable and -Xelide-below. In Scala 3, assert is an inline method
that calls scala.runtime.Scala3RunTime.assertFailed, and passing -Xelide-below:2001 to 3.10.0 prints "bad option
… was ignored": the assertion still fired. Treat assert as always on, and use require for input validation. Java’s
assert statement is disabled by default and enabled with -ea; Kotlin has require, check and assert with similar
meanings. For validation that should be reported without exceptions, return an Either.
Source: Scala API: Predef
Checked exceptions: CanThrow and saferExceptions (experimental)
Scala 3 has an experimental checked-exceptions system, enabled per file with import scala.language.experimental.saferExceptions.
It is experimental: its design may change, it needs no compiler flag but may only be used from code that opts in, and
(like all experimental features) it is not guaranteed to work the same on the 3.9 LTS and later lines. It rests on two
ideas. First, throw of an exception E requires a capability: a given instance of CanThrow[E] must be in scope.
Second, the type T throws E is sugar for the context function CanThrow[E] ?=> T. A try block with a catch case for
E provides the capability in its body, so the compiler can check that every checked exception is either caught or declared.
//> using scala 3.10.0
import scala.language.experimental.saferExceptions
class WeatherUnavailable(msg: String) extends Exception(msg)
def forecast(day: Int): String throws WeatherUnavailable =
if day > 3 then throw WeatherUnavailable(s"no forecast for day $day")
else "sunny"
def twoDays(a: Int, b: Int)(using CanThrow[WeatherUnavailable]): String =
forecast(a) + "/" + forecast(b)
@main def safer(): Unit =
try
println(forecast(1))
println(twoDays(2, 9))
catch case e: WeatherUnavailable => println(s"caught: ${e.getMessage}")
sunny
caught: no forecast for day 9
Calling forecast from a method that neither declares nor catches the exception, as in
def plan(day: Int): String = forecast(day), is a compile-time error:
[error] ./b1.scala:11:16
[error] The capability to throw exception WeatherUnavailable is missing.
[error] The capability can be provided by one of the following:
[error] - Adding a using clause `(using CanThrow[WeatherUnavailable])` to the definition of the enclosing method
[error] - Adding `throws WeatherUnavailable` clause after the result type of the enclosing method
[error] - Wrapping this piece of code with a `try` block that catches WeatherUnavailable
[error]
[error] The following import might fix the problem:
[error]
[error] import unsafeExceptions.canThrowAny
[error]
[error] forecast(day)
[error] ^
The import the compiler suggests, import unsafeExceptions.canThrowAny, provides the capability for every exception and so
turns the check off again (adding it to the failing file makes it compile and print sunny); that is how checked code
calls unchecked code. Because the capability is an ordinary context parameter, higher-order functions such as
map stay polymorphic in the exceptions their argument throws, which is the problem that made Java’s checked exceptions
unpopular. The reference states that the feature is published to gather feedback and that it is a stepping stone toward
capture checking; do not rely on it in a library you publish.
Choosing a mechanism
-
Exceptions are for situations the immediate caller cannot sensibly handle (I/O failures, programming errors, violated contracts). They cost nothing on the happy path and give stack traces.
-
Option,EitherandTrymodel failures the caller is expected to handle as values, and compose withmap,flatMapandfor. See Option & Null Safety and Either & Try. -
boundaryandbreakare for leaving loops and nested lambdas early; combine them withEitherandOptionto write "early return on error" code without a deep chain offlatMap. -
requireandassertdocument preconditions and invariants;Usingguarantees resource release.
Where older books are outdated
Books written for Scala 2 describe return inside closures as normal, teach scala.util.control.Breaks for loop exits, and
use try { ... } finally { ... } around close() calls or the loan pattern where scala.util.Using (2.13) now exists. They
use braces and catch { case ... } where Scala 3 allows catch case ... with indentation, and they do not know
boundary (3.3) or CanThrow. Early Scala 3 material may say that checked exceptions are impossible: they remain a
Java-only concept except for the experimental saferExceptions.