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.

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 { }.

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]: body runs body and returns its value; it defines a label (a boundary.Label[T] given instance) in scope.

  • boundary.break(value) unwinds to the nearest enclosing boundary whose type accepts value, and makes that boundary return value.

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

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

require(cond, msg)

IllegalArgumentException("requirement failed: ...")

Preconditions on arguments: the caller is wrong.

assert(cond, msg)

AssertionError("assertion failed: ...")

Internal invariants: this code is wrong.

assume(cond, msg)

AssertionError("assumption failed: ...")

Facts the code relies on without checking them (a verifier may take them as given).

expr.ensuring(cond, msg)

AssertionError("assertion failed: ...")

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.

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, Either and Try model failures the caller is expected to handle as values, and compose with map, flatMap and for. See Option & Null Safety and Either & Try.

  • boundary and break are for leaving loops and nested lambdas early; combine them with Either and Option to write "early return on error" code without a deep chain of flatMap.

  • require and assert document preconditions and invariants; Using guarantees 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.