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.

Advertisement

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")
How a Try moves through a pipeline: exceptions become values, values stay valuesTry(expr)evaluated nowSuccess(value)Failure(throwable)returnsNonFatal thrownmap / flatMapf runsmap / flatMapskippedf throwsrecoverback to SuccesshandledNot caught: fatal errorsVirtualMachineError, LinkageError,InterruptedException, ControlThrowableNot caught: escaped lazinessIterator, LazyList, view, FutureExitsfold, getOrElse, toEitherpropagatesOnly the code that runs inside Try.apply, map, flatMap, recover or transform is protected.
A Try is created eagerly. Non-fatal exceptions become Failure; functions passed to map and flatMap are themselves protected; fatal errors and work deferred by laziness escape.

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:

ThrowableWhy it escapes
VirtualMachineErrorIncludes OutOfMemoryError and StackOverflowError: the JVM itself is in trouble, and a value cannot represent that safely.
LinkageErrorClass loading or binary compatibility failures, usually a deployment bug.
InterruptedExceptionThread cancellation must reach the code that manages the thread, not be stored in a value.
ControlThrowableScala's own control flow, such as non-local return and breaks; catching it would break the language.
ThreadDeathLegacy 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.

Advertisement

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.

JobMethodsBehaviour
Transform successmap, flatMap, filterRun only on Success. filter turns a false predicate into Failure(NoSuchElementException).
Handle failurerecover, recoverWith, orElsePartial functions over the exception; recoverWith returns another Try; orElse supplies an alternative Try.
Handle bothtransform, foldtransform takes one function per side, each returning Try; fold collapses to a plain value.
Leave TrygetOrElse, toOption, toEither, getget rethrows the stored exception; toOption discards it.
InspectisSuccess, isFailure, failed, foreachfailed 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 inside map.
  • Try around a Future. The Future was created successfully, so you get Success wrapping a future that may fail. Handle errors on the Future itself.
  • Non-local return. A return inside 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. get rethrows, so a chain that ends in get is just exception code with extra allocations. Use fold, pattern matching or getOrElse.
  • Swallowing. toOption and a catch-all recover throw away the reason. Log or count the exception at the point you discard it.
  • Law-breaking. Success(x).flatMap(f) returns Failure when f throws, while f(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

  1. Grep your codebase for .get on Try values and replace each with fold, pattern matching or getOrElse.
  2. Find every catch { case e: Throwable and change it to NonFatal, or to the specific exception you expect.
  3. Check each Try that wraps an iterator, LazyList, view or Future, and force or restructure it.
  4. Replace hand-written try and finally around resources with Using, materialising results inside the block.
  5. At each layer boundary, convert Try into Either with a domain error type.
  6. Write one test per Try-returning function that proves late failures are captured.
Key takeaway: Try turns non-fatal exceptions thrown by an eagerly evaluated expression into Success or Failure values, and protects every function passed to its combinators. It deliberately lets fatal errors, interrupts and control throwables through. Use it at the boundary with throwing code, use Using for resources, force lazy work inside the block, never end a chain with get, and convert to Either with a domain error type once the meaning of a failure is known.