Throwing an exception in Try.map should result in an exception being thrown, not a Failure result

Perhaps this is an uncommon sentiment, but I have always expected the behavior of Try.map to be similar to Option.map, ie equivalent to the similar pattern match:

try match {
  case Success(result) => Success(handler(result))
  case Failure(exception) => Failure(exception)
}

However, I was very surprised today to discover that this is not the case. Instead, the behavior is this:

try match {
  case Success(result) => Try(handler(result))
  case Failure(exception) => Failure(exception)
}

This is very significant because it can unexpected result in the ‘loss’ of errors thrown in the handler method. For example, in this case:

Try(action).map(handler)

I expect a Failure to indicate that action failed, not that handler failed. If I wanted to safely handle errors in the handler, I would have done:

Try(action).flatMap(Try(handler))

Now I need to go back and check all of my old code for any instances where I may have used Try.map without realizing that I might be hiding potentially critical exceptions. At the very least, I think this behavior should be more clearly documented.

I am interested in hearing other opinions, however. Maybe it’s obvious that it would work this way and I should have known.

3 Likes

If it helps ease your mind, Try is fail-fast, so you shouldn’t be losing exceptions, it’ll just be a bit less clear where they’re coming from.

That being said, there’s definitely a spectrum between, “favor increased regularity” and “favor decreased chance of an exception escaping Try’s context”.

Personally, I like the former, and (as the behavior of Try#map probably can’t change without breaking stuff), I usually get it by importing cats.syntax.either._ and using the Either.catchNonFatal method it provides. Might be worth looking into :slight_smile:

4 Likes

Try is different from Either in that it catches exceptions. If it wouldn’t handle exceptions in map, then I don’t really see much of a point in having Try beyond the constructor.

You could have minimal non-try with

type Try[A] = Either[Throwable, A]
object Try {
  def apply(a: => A) = try Right(a) catch { case NonFatal(nf) => Left(nf) }
}

but I don’t really see much of a point for that.

Agreed on the documentation though, operations that catch exceptions should be clearly documented that that’s what they do.

3 Likes

Hello!

The reason why it behaves like that is that the goal of the type Try is to replace exceptions with values. So, instead of writing this:

def divide(a: Int, b: Int): Int =
  if (b == 0) throw new Exception("Division by zero")
  else a / b

You write that:

def tryDivide(a: Int, b: Int): Try[Int] =
  if (b == 0) Failure(new Exception("Division by zero"))
  else Success(a / b)

Why do we want to do that? Because if you look at the signature of divide, it is not explicit that the method may throw an exception. Now, if we always model failures with exceptions, any method call becomes scary because we don’t know (from its signature) if that method is going to surprisingly throw an exception or to return a value. This can lead to a defensive programming style where we would surround every method call by an exception handler. That style of programming would be very cumbersome.

The main idea behind the type Try is to make it explicit that a computation may fail, so that users don’t need to be defensive. Indeed, by returning a Try[Int], the method tryDivide explicitly informs its callers that it can either return a Success or a Failure. There are no more surprises, and no need to be defensive.

As I said above, Try is a tool for replacing exceptions with values. So, if the implementation of the method map was the one you suggest, that method would possibly throw exceptions, which would be very confusing for end-users, because users of Try expect failures to be returned as Failures.

4 Likes

Hello, thanks for the replies. I do understand that the purpose of Try is functional error handling rather than unexpected exceptions. The pattern essentially introduces an alternative ‘mode’ of error-handling in addition to the try/catch style. In the case of handling the result, I guess I didn’t expect to still be in functional error-handling ‘mode’ outside of the explicit Try context.

In a somewhat contrived example, if I have your division methods, I might do something like this:

tryDivide(a, mayBeZero).map(divide(_, neverZero)) match {
  case Success(result) => log("Result:", result)
  case Failure(_) => log("Could not perform division because mayBeZero is zero.")
}

In this case I safely handle the case where mayBeZero is zero because I know it’s a possibility. On the other hand, I am sure that in no reasonable scenario will neverZero be zero, so I’m OK with throwing an exception if it is. However, because of how map works, the result if neverZero is zero will still be Failure(Exception("Division by zero")), silently hiding the situation that I considered to be fatal and thought would throw. If I had wanted that behavior, I would have just done tryDevide(a, b).flatMap(tryDivide(_, c)).

Anyway, I guess the argument can be made either way and there is little to no likelihood of changing this behavior since it could break existing code - I do think it’s worth it to clarify the documentation. As someone who hasn’t contributed to Scala before, could you point me to where I might be able to open a PR for the documentation change?

5 Likes

The source lives in github, the scala/scala repository: https://github.com/scala/scala

You can read the readme and contributors guidelines if you want, check out the code, make the changes, build the documentation locally with the sbt doc task, check if it did what you intended, and submit a PR.

Or you could, eh, not do that, live dangerously, edit the scaladoc in the web-editor, create a PR, and pretend you built it and checked that you got the right output.

Anyway, for map, this is the place where to fix it: scala/Try.scala at 2.13.x ¡ scala/scala ¡ GitHub

1 Like

For your use case, Either[Throwable, A] is probably a better choice. Cats’ syntax extensions for either, Cats: Either makes it easy to lift your throwing function in to either. You can then map with the expected semantics.

There might be more work to do though.

The top-level scaladoc says

‘‘Note:’’: all Try combinators will catch exceptions and return failure unless otherwise specified in the documentation.

If maps behaviour is unexpected to you, then the probably also goes for the rest. None of these throw:

Try(???).recoverWith{ case _: Throwable => ???}
Try(???).recover{ case _: Throwable => ???}
Try(1).map(_ => ???)
Try(1).flatMap(_ => ???)
Try(1).collect{ case 1 => ???}
Try(1).filter{ _ => ???}
Try(1).transform(  _ => ???, _ => ???)
Try(???).transform(  _ => ???, _ => ???)

Thank you for the illustrative example. In that situation, I second @martijnhoekstra’s advice: you should switch to a different data type, such as Either. There is a handy method toEither on the type Try:

tryDivide(a, mayBeZero).toEither.map(divide(_, neverZero)) match {
  case Right(result) => log("Result:", result)
  case Left(_) => log("Could not perform division because mayBeZero is zero.")
}
2 Likes