Most Scala code eventually calls something that throws: a Java library, toInt on user input, a file read, a JSON parser. scala.util.Try is the standard library's way to turn those exceptions into ordinary values, so that a failure can be passed around, transformed and combined with other results instead of unwinding the stack to whoever happens to catch it.
This article explains exactly what Try catches and what it lets through, how its combinators behave, how it relates to Using and Future, and where it fits next to Either. The examples use Scala 3 syntax; everything also applies to Scala 2.13 except where noted. By the end you should be able to wrap exception-throwing code safely, recognise the four or five traps that make Try lie, and decide when a different type is the better tool.
The type and the constructor
Try[T] is a sealed abstract class with two final case classes: Success[T](value: T) and Failure[T](exception: Throwable). It is covariant in T, and the error side is always Throwable, which is both its convenience and its main limitation.
You usually create one with Try(expr). The argument is by-name, so it is evaluated inside a try block, and it is evaluated immediately: Try is not lazy and does not describe a computation for later. If the expression returns, you get Success; if it throws a non-fatal exception, you get Failure holding that exception, stack trace included. You can also build them directly, for example Failure(ConfigError("missing port")).
import scala.util.{Try, Success, Failure}
val port: Try[Int] = Try(sys.env("PORT").toInt) // NoSuchElementException or NumberFormatException
port match
case Success(p) => println(s"listening on $p")
case Failure(ex) => println(s"bad PORT: ${ex.getMessage}")
val checked: Try[Int] =
port
.filter(p => p > 0 && p < 65536) // Failure(NoSuchElementException) if false
.recover { case _: NoSuchElementException => 8080 }
val asEither: Either[Throwable, Int] = checked.toEither
val text: String = checked.fold(ex => s"error: $ex", p => s"port $p")What NonFatal catches, and why it skips some throwables
Try catches using the scala.util.control.NonFatal extractor, not case e: Throwable. That extractor deliberately does not match a short list of throwables that a program should not try to continue after:
| Throwable | Why it escapes |
|---|---|
VirtualMachineError | Includes OutOfMemoryError and StackOverflowError: the JVM itself is in trouble, and a value cannot represent that safely. |
LinkageError | Class loading or binary compatibility failures, usually a deployment bug. |
InterruptedException | Thread cancellation must reach the code that manages the thread, not be stored in a value. |
ControlThrowable | Scala's own control flow, such as non-local return and breaks; catching it would break the language. |
ThreadDeath | Legacy thread stop signal, also excluded. |
This is a design choice you should copy in your own handlers: if you write catch { case NonFatal(e) => ... } rather than catching Throwable, you get the same behaviour. A StackOverflowError from deep recursion therefore propagates straight through Try; fix the recursion rather than expecting Try to contain it.
The combinators, grouped by job
Every function passed to a Try combinator is itself run inside a try block: if f throws a non-fatal exception during map(f) or flatMap(f), the result is a Failure, not a thrown exception. That is what makes chaining safe.
| Job | Methods | Behaviour |
|---|---|---|
| Transform success | map, flatMap, filter | Run only on Success. filter turns a false predicate into Failure(NoSuchElementException). |
| Handle failure | recover, recoverWith, orElse | Partial functions over the exception; recoverWith returns another Try; orElse supplies an alternative Try. |
| Handle both | transform, fold | transform takes one function per side, each returning Try; fold collapses to a plain value. |
| Leave Try | getOrElse, toOption, toEither, get | get rethrows the stored exception; toOption discards it. |
| Inspect | isSuccess, isFailure, failed, foreach | failed turns Failure(e) into Success(e), handy in tests. |
Prefer recover with a specific exception type over a catch-all case. Recovering from NumberFormatException with a default is a decision about bad input; recovering from every exception with a default hides bugs such as a null pointer in your own code.
Worked example: a configuration loader
Here is a realistic task: read a small key=value file, parse three settings, validate the pool size and return a typed configuration or a failure explaining what went wrong. Everything that can throw, the file read, the integer parse, is wrapped, and the for-comprehension stops at the first failure.
import scala.io.Source
import scala.util.{Try, Using}
final case class DbConfig(host: String, port: Int, poolSize: Int)
final case class ConfigError(msg: String) extends Exception(msg)
def parseLine(line: String): Try[(String, String)] =
line.split("=", 2) match
case Array(k, v) => Try((k.trim, v.trim))
case _ => scala.util.Failure(ConfigError(s"malformed line: $line"))
def field(kv: Map[String, String], key: String): Try[String] =
kv.get(key).toRight(ConfigError(s"missing $key")).toTry
def loadDb(path: String): Try[DbConfig] =
for
lines <- Using(Source.fromFile(path))(_.getLines().toList) // materialise inside Using
pairs <- lines.filter(_.contains("=")).foldLeft(Try(List.empty[(String, String)])) {
(acc, line) => acc.flatMap(xs => parseLine(line).map(_ :: xs))
}
kv = pairs.toMap
host <- field(kv, "db.host")
port <- field(kv, "db.port").flatMap(s => Try(s.toInt))
pool <- field(kv, "db.pool").flatMap(s => Try(s.toInt))
.filter(n => n >= 1 && n <= 200)
yield DbConfig(host, port, pool)Walk through the data flow for a file whose db.pool is 500. Using opens the file, reads every line into a List and closes the file, returning Success(lines). Parsing produces pairs; host and port succeed; pool parses to 500 and then filter fails it with NoSuchElementException. The yield is skipped and the caller gets that Failure.
Notice the weakness this exposes. The caller learns that a predicate failed, not that the pool size was out of range, because filter invents its own exception. When the error message matters, replace the filter with an explicit check: if n <= 200 then Success(n) else Failure(ConfigError("db.pool out of range")). Also notice that the loader stops at the first problem; a user fixing a config file would rather see all of them, which is a job for accumulating validation in functional error handling, not for Try.
Using: resources that close on every path
Scala 2.13 added scala.util.Using, and it returns a Try. Using(resource)(f) runs f, closes the resource whether f returned or threw, and returns Success or Failure. If both the body and close() throw, the close exception is attached to the primary one as a suppressed exception rather than replacing it. Using.resource is the variant that throws instead of returning Try, and Using.Manager manages several resources that are closed in reverse order of acquisition.
The rule that matters: materialise inside the block. In the loader, getLines().toList reads everything before the file closes. Returning the iterator itself would compile and then fail later, after the source was closed, which is one form of the laziness trap below.
Try and Future
Try is the result type of asynchronous Scala. Future.apply catches non-fatal exceptions exactly as Try.apply does and stores them as a failed future; onComplete callbacks receive a Try[T]; a Promise is completed with a Try; and Future.fromTry lifts a synchronous result into an already-completed future.
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.util.{Try, Success, Failure}
def fetchUser(id: Long)(using ExecutionContext): Future[User] =
Future(blockingClient.get(id)) // a NonFatal throw becomes a failed Future
val parsed: Try[Long] = Try(rawId.toLong)
val user: Future[User] = Future.fromTry(parsed).flatMap(fetchUser)
user.onComplete { // the callback receives a Try
case Success(u) => audit(u)
case Failure(ex) => metrics.increment("user.fetch.failed")
}
// Normalise both outcomes into a value without losing the error type:
val outcome: Future[Either[Throwable, User]] = user.transform(t => Success(t.toEither))transform on a Future receives the completed Try and returns a new one, which makes it the right place to translate exceptions into domain errors at a boundary. See Futures and execution contexts for how the callbacks are scheduled.
When you bridge a callback-based Java API, a Promise is the adapter: complete it from the callback with promise.complete(Try(result)) or promise.failure(ex), and hand out promise.future. Wrapping the conversion in Try matters because an exception thrown inside a library callback would otherwise be lost on the library's thread and the future would never complete, leaving the caller waiting until a timeout. Use tryComplete instead of complete when more than one callback could fire, since completing a promise twice throws.
The traps
Try fails silently in a handful of recognisable ways. All of them come from one fact: only code that runs inside its try block is protected.
// 1. Laziness escapes: the exception happens after Try has returned Success.
val numbers = Try(lines.iterator.map(_.toInt)) // Success(iterator)
numbers.map(_.sum) // sum throws here, inside map: Failure
numbers.get.sum // outside Try: throws to the caller
// 2. Try around a Future checks only that the Future was created.
val wrong: Try[Future[Int]] = Try(Future(riskyCall())) // Success(failed future)
// 3. Swallowing: toOption discards the reason, and get rethrows it.
val silent: Option[Int] = Try(parse(s)).toOption // why did it fail? nobody knows- Escaped laziness. Iterators,
LazyList, collection views and by-name parameters defer work. Force the computation inside the Try, or do the risky step insidemap. - Try around a Future. The Future was created successfully, so you get
Successwrapping a future that may fail. Handle errors on the Future itself. - Non-local return. A
returninside a lambda in Scala 2 is implemented with a control throwable, which NonFatal ignores, so it passes through Try. Scala 3 deprecates non-local returns; restructure the code. - Calling get.
getrethrows, so a chain that ends ingetis just exception code with extra allocations. Usefold, pattern matching orgetOrElse. - Swallowing.
toOptionand a catch-allrecoverthrow away the reason. Log or count the exception at the point you discard it. - Law-breaking.
Success(x).flatMap(f)returns Failure whenfthrows, whilef(x)throws. Try is a useful monad-like type, not a lawful monad, which matters only if you rely on equational reasoning with throwing functions.
Try, Either, Option or an effect type
Use Try at the edge, where Java or library code throws and you want a value. Convert to Either with a domain error type once you know what the failure means, because Throwable tells the caller nothing about which failures are expected. Use Option for absence that is not an error. Inside an effect system such as Cats Effect or ZIO, lift with IO.fromTry or ZIO.fromTry and let the effect handle errors from there, since Try's eager evaluation does not fit a description of a computation.
On performance, the expensive part is the exception, not the Try: filling in a stack trace walks the stack. In hot paths where failures are frequent and expected, such as validating millions of records, return an Either of a plain error value instead, or make your own exception extend scala.util.control.NoStackTrace. For occasional failures, Try's cost is a small allocation and is negligible.
Testing code that returns Try
Test both branches explicitly. Assert on result.isSuccess and the value for the happy path, and use result.failed.get to retrieve the exception and check its type and message for the failure path. Add a test that passes input designed to throw late, such as a malformed line at the end of a large file, to prove that laziness has not escaped the Try. Property-based tests are a good fit: generate arbitrary strings for a parser and assert that it never throws, only ever returns Success or Failure.
What to do next
- Grep your codebase for
.geton Try values and replace each with fold, pattern matching or getOrElse. - Find every
catch { case e: Throwableand change it to NonFatal, or to the specific exception you expect. - Check each Try that wraps an iterator, LazyList, view or Future, and force or restructure it.
- Replace hand-written try and finally around resources with
Using, materialising results inside the block. - At each layer boundary, convert Try into Either with a domain error type.
- Write one test per Try-returning function that proves late failures are captured.