4 Problems with Java's Exceptions and How Scala Can Help

Trudy Firestone

Reading time: about 8 min

Topics:

  • Architecture
Error handling is important for many common operations—from dealing with user input to making network requests. An application shouldn’t crash just because a user enters invalid data or a web service returns a 500. Users expect software to gracefully handle errors, either in the background or with a user-friendly and actionable description of the issue. Unfortunately, since dealing with exceptions can be messy and complicated, error handling often comes into play as an almost-forgotten last step for polishing an application. We’ll cover four ways dealing with Java's exceptions can be difficult and then wrap up with a few ways modern languages like Scala can help programmers make sure they correctly handle errors.

1. Exceptions are easy to miss

Java's exceptions allow the caller of a function to ignore any errors the function might produce. If the program completely fails to catch an exception, the program will crash. While ignoring error cases can be useful for putting together a quick prototype, it can be difficult to track down all the places where an exception can be thrown when you attempt to ready an application for production. Java introduced checked exceptions in an attempt to solve this problem by enforcing either annotating the function that might throw that exception or catching the exception immediately. While checked exceptions are somewhat guarded by the compiler, it’s still too easy to add a throws clause or wrap the exception in a try/catch block without paying any attention to the error case and neglecting to handle the exception properly. In addition, only a small portion of Java’s exceptions are checked, so many exceptions are still very easy to miss.

2. Exception control flow is hard to follow

If exceptions are a common or even essential part of your application, it becomes increasingly difficult to understand the codebase as it grows larger and more complex. Rather than following the usual flow of data through parameters and return values, Java's exceptions occur outside the normal function pattern, resulting in confusing and fragile code. As an example, consider the diagram below.
Exception Control Flow

The blue arrow highlights that any remaining statements will be skipped once an exception is thrown. The green arrow shows how an exception can jump several levels up the stack before being handled in a catch.

As you can see, it can be difficult to remember if an exception has been handled previously without drawing a diagram (such as the one above) to keep track of everything. As a result, the top-level function often ends up acting as a catch-all, making the errors much less meaningful because they are handled far from their source. In a complex codebase, it’s also easy to forget that a function might throw an exception, causing you to erroneously reuse it without wrapping the call in a try/catch. This results in a crash, as demonstrated in Component 2 of the diagram above. With all of these factors, removing the error from the normal path of data by wrapping it in an exception adds an unnecessary level of complexity, making the overall result of a code path harder to predict.

3. Normal events are treated as exceptional

Quite often, Java's exceptions are used in ways that make normal behavior seem unexpected. For example, if a user is supposed to type in a date, but they type “hello” instead, code that’s meant to parse the date might throw an exception instead of returning a Date object. Suddenly, the perfectly ordinary occurrence of a user not following guidelines becomes an exceptional case and the function caller is responsible for remembering to handle the exception. As another example, suppose the program throws an exception when a network request returns a 404 Not Found error. While the client may initially expect an endpoint to continue to exist, it’s not unreasonable for the endpoint to be removed. Although throwing an exception on 404 responses may initially seem like a good idea, it treats a common occurrence as an exceptional one, making it easy to forget to handle it properly.

4. Exceptions are runtime, not compile-time, errors

Exceptions can be used to handle unusual states, so it’s easy to miss them when testing. Although we generally test major user flows and any edge cases we can think of, error cases are often left out because they can be difficult to reproduce. Exceptions may also hide in edge cases that you forget to test. Because Java's exceptions are checked at runtime, simply compiling the code is not enough to make sure the error cases are properly handled. The error has to actually be triggered. However, it’s often possible to use better types which can move error checking from runtime check to a compile-time one. For instance, using an Option instead of null can help to avoid NullPointerExceptions.

A few of Scala’s solutions to the problems with exceptions

Of course, you can use the classic try/catch when handling an exception directly, but using the following types in Scala makes it easier to clearly return the possibility of an error state via the type system.

Try

When a call to an external library or API can throw an exception, you can wrap the result in the Try type and return that value.
def parseInt(s: String): Try[Int] = {
  Try(Integer.parseInt(s))
}

This forces the caller of the function to recognize that parseInt can have an error state. The Try object will either contain the parsed Int or the exception object (in this case, a NumberFormatException). In order to access the desired value, the caller will have to handle the error, either by mapping over the Try and passing the error up the chain or by directly handling both the Success and Failure cases.

// Parses a String into an Int and then adds 1.
// If parsing fails, the error is passed up the chain.
def plus1(s: String): Try[Int] = {
  parseInt(s).map(_ + 1)
}
Try demo 1
// Parses a String into an Int.
// If parsing fails, the error is handled by returning the length of the String.
def parseIntOrGetStringLength(s: String): Int = {
  parseInt(s) match {
    case Success(i) => i
    case Failure(e) =>
      // Easy to add logging here
      s.length
  }
}
Try demo 2

Either

When handling your own error cases, you can use an Either to return either the value or some sort of error object. Scala’s Either has a Left and a Right value. By convention, the Left value is the error object.
def substring(s: String, start: Int): Either[Error, String] = {
  if (start <= s.length) {
    Right(s.substring(start))
  } else {
    Left(Error("Start index out of bounds. String was too short to get substring."))
  }
}

Similarly to Try, the Either type forces the caller to handle the error case in some manner. By using Scala 2.12’s right biased Either or the cats library in earlier versions of Scala, you can map over the Right of an Either like you can map over a Try. Alternatively, you can handle the Right and Left cases directly.

// Gets the length of a substring.
// If getting the substring fails, the error is passed up the chain.
def stringLengthAfterSubstring(s: String, start: Int): Either[Error, Int] = {
  substring(s, start).map(_.length)
}
Either demo 1
// Gets the length of a substring.
// If getting the substring fails, the error is handled by returning a default of 0.
def stringLengthAfterSubstringWithDefault(s: String, start: Int): Int = {
  substring(s, start) match {
    case Right(result) => result.length
    case Left(error) =>
      // Log error
      0
  }
}
Either demo 2
A more in-depth guide to Try and Either can be found in The Neophyte’s Guide to Scala. Exceptions need to be handled with care. When misused, they can make your program unnecessarily complex. In the worst case, they can cause your program to crash unexpectedly. Luckily, Scala provides a few types that make it easy to move the risk of exceptions into the safety of the type system. By using the Try and Either types and handling unavoidable exceptions as close to the source as possible, it’s a lot easier to make a robust, user-friendly application.

About Lucid

Lucid Software is a pioneer and leader in visual collaboration dedicated to helping teams build the future. With its products—Lucidchart, Lucidspark, and Lucidscale—teams are supported from ideation to execution and are empowered to align around a shared vision, clarify complexity, and collaborate visually, no matter where they are. Lucid is proud to serve top businesses around the world, including customers such as Google, GE, and NBC Universal, and 99% of the Fortune 500. Lucid partners with industry leaders, including Google, Atlassian, and Microsoft. Since its founding, Lucid has received numerous awards for its products, business, and workplace culture. For more information, visit lucid.co.

Get Started

  • Contact Sales

Products

  • Lucidspark
  • Lucidchart
  • Lucidscale
PrivacyLegalCookie privacy choicesCookie policy
  • linkedin
  • twitter
  • instagram
  • facebook
  • youtube
  • glassdoor
  • tiktok

© 2024 Lucid Software Inc.