Field Notes

Scala/Play, Part 13: Scala Try for Java Developers - Capture, Compose and Recover

Scala/Play, Part 13: Scala Try for Java Developers - Capture, Compose and Recover

Coming from Java and Spring, I already know how to surround a throwing operation with try/catch. The interesting part of Scala’s Try is what happens afterwards: the outcome becomes a value I can pass to another method, transform, compose, or deliberately recover from.

In Part 12, I used Either to return a result or a reason. Here, I am dealing with operations that can throw. These examples come from my Scala 2.13.18 learning lab; there is no Play controller or asynchronous work involved yet.

Zakaria’s navy-hoodie mascot selects an intact miniature railway route while a broken route remains blocked.
A fallback is a deliberate alternative, not a claim that every failure has been repaired.

Capture the operation, not its aftermath

The starting point is a text conversion. Java’s Integer.parseInt is the familiar comparison: numeric text gives an integer; invalid text throws. My Scala boundary is small:

import scala.util.{Failure, Success, Try}

def parsePremium(text: String): Try[Int] = {
  Try(text.toInt)
}

The braces in the import bring three names into scope. Try[Int] describes an outcome carrying either an integer in Success, or the actual exception in Failure—not merely an error-message string.

parsePremium("600")   // Success(600)
parsePremium("hello") // Failure containing NumberFormatException

Try(...) evaluates the expression at that call, inside its exception-handling boundary. It is not lazy scheduling and does not move work to another thread. A conversion performed before entering that boundary cannot be caught retroactively.

The Scala 2.13.18 API also makes the limit important: Try catches non-fatal exceptions, not every possible Throwable. And successfully parsing "-1" says nothing about whether a negative premium is valid business input.

Start with match, then use map

For this fictional example I divide a whole-euro premium into installments. This deliberately tiny integer calculation is not a production payment schedule. Before shortening anything, here is the explicit behavior:

def installmentWithMatch(
    result: Try[Int], installments: Int
): Try[Int] = {
  result match {
    case Success(premium) => Try(premium / installments)
    case Failure(error)   => Failure(error)
  }
}

Parsing may have succeeded while division still fails. That is why the success branch uses Try(...), not Success(...). Constructing Success alone does not catch an exception thrown while evaluating its argument.

The equivalent transformation in the lab is:

def installmentWithMap(
    result: Try[Int], installments: Int
): Try[Int] = {
  result.map((premium: Int) => premium / installments)
}

The callback receives an Int and returns an Int. With 600 and 12, the result is Success(50). An existing parse failure skips the callback. With divisor zero, the callback throws and map captures an ArithmeticException in a new failure.

The next method already returns Try

I then moved the calculation behind its own boundary:

def calculateInstallment(
    premium: Int, installments: Int
): Try[Int] = {
  Try(premium / installments)
}

Now the callback returns Try[Int], rather than an integer. That changes the result shape:

val parsed = parsePremium("600")

parsed.map((premium: Int) => calculateInstallment(premium, 12))
// Success(Success(50))

parsed.flatMap((premium: Int) => calculateInstallment(premium, 12))
// Success(50)
map produces nested Try results while flatMap returns one Try layer, for both successful division and division by zero.
The helper’s return type determines whether another wrapper would be added.

The zero-divisor case made the difference especially clear. The helper catches the exception and returns a Failure. Therefore, outer map produces Success(Failure(...)): its callback returned normally. flatMap instead uses the returned failure directly.

Returning a failure value is not throwing an exception. Both approaches preserve an existing parse failure without invoking the calculation. The lab separately checks that flatMap also captures a non-fatal exception thrown by its callback before the callback returns a Try.

A for-comprehension keeps the same behavior

Once the chain works, I can express its dependencies more readably:

def installmentLabelWithFor(
    text: String, installments: Int
): Try[String] = {
  for {
    premium <- parsePremium(text)
    amount  <- calculateInstallment(premium, installments)
  } yield premium.toString + " EUR / " +
    installments + " = " + amount + " EUR"
}

Each <- binds the successful value: both names are integers, not wrappers. The second operation depends on the first. The yield builds a plain string; it does not need an extra Success.

For this two-generator expression, the equivalent method chain is:

parsePremium(text).flatMap((premium: Int) => {
  calculateInstallment(premium, installments).map((amount: Int) => {
    premium.toString + " EUR / " + installments + " = " + amount + " EUR"
  })
})

There is no additional catch mechanism hidden in for. The behavior comes from these operations: a parse failure skips calculation and label creation; a calculation failure skips the label.

Recover only when the fallback means something

My display can accept “Premium unavailable” for invalid numeric text. It cannot pretend division by zero succeeded. First, the explicit policy:

def labelRecoveryWithMatch(result: Try[String]): Try[String] = {
  result match {
    case Success(label) => Success(label)
    case Failure(_: NumberFormatException) =>
      Success("Premium unavailable")
    case Failure(error) => Failure(error)
  }
}

In _: NumberFormatException, the type pattern selects that exception type, including subtypes; the underscore means I do not need a local name. This is similar to choosing a specific Java catch clause. The shorter form is:

def labelWithRecovery(result: Try[String]): Try[String] = {
  result.recover {
    case _: NumberFormatException => "Premium unavailable"
  }
}

recover supplies the exception inside the failure, so the pattern no longer includes Failure(...). The handler returns a plain string; recovery wraps it in Success. Unmatched failures and existing successes pass through unchanged.

Targeted recovery leaves Success unchanged, replaces NumberFormatException with a successful display label, and preserves ArithmeticException.
The selected fallback changes the display outcome, not the failed calculation.

These are the verified outcomes, with exception details abbreviated:

labelWithRecovery(installmentLabelWithFor("600", 12))
// Success("600 EUR / 12 = 50 EUR")

labelWithRecovery(installmentLabelWithFor("hello", 12))
// Success("Premium unavailable")

labelWithRecovery(installmentLabelWithFor("600", 0))
// Failure containing ArithmeticException

The result remains Try[String]. Nothing retries, and the original failed value remains unchanged. Crucially, I supplied display text—not a fabricated premium or a zero-euro payment.

Run the exact lesson

The complete lesson and assertions are pinned to commit 9f8061c. From the lab root:

sbt "runMain learning.PremiumLesson"

I reran the full entry point with Scala 2.13.18, sbt 1.12.15 and Java 21. All assertions passed, including skipped callbacks, nested results, both failure stages, targeted recovery, and preservation of the original failure.

My recall rule is simple: map transforms a successful value; flatMap chains an operation returning Try; recover supplies a value for a selected failure. The version-pinned implementation is there when I want to check the mechanics. For now, I have a useful exception boundary and explicit decisions about what may happen next.