Read on to learn how to solve all the problems of exceptions by instead returning values, an introduction to the Result
class, and how it makes handling unexpected errors a breeze. Finally, a quick note about how it relates to actual functional programming.
In the previous article, we showed how exceptions were basically GOTO in disguise, broke fundamental OOP principles and resulted in code that was prone to incorrect error handling, difficult to reason about and time-consuming to write correctly.
Here are the specific problems we want to solve:
- We want to preserve normal control flow. This means that we don’t want execution to jump a thousand light-years away from the place the error happens.
- We want to deal with all error states, not just the “typical” ones
- We want to decouple our code from the specific exceptions thrown by outside code as much as possible
- We want to write less code
While this may seem like a tall order, it’s actually not.
Here are some observations:
- Normal control flow is preserved when we exit the function by returning a value. The moment we stop throwing exceptions, we have no other way of exiting a function, so when an exception gets thrown, we need to stop re-throwing it as quickly as possible. Since exceptions are, almost without exception (ha), eventually converted to values, what we’re really saying is that we need this conversion to happen as close to the place the error happened as possible — i.e. we need to move the code that does the conversion to values as deep as we can.
- In 99% of the cases, all you do is transform the
Throwable
into a string representation that gets sent to the client (and maybe written to the log). You don't care about the type. For this reason, we can just catchThrowable
(which is actually okay to do, with certain limitations) and not worry about its specific type. - The remaining 1% of cases are the consequences of specific business requirements, which makes them part of the business (i.e. service) logic. If a business requirement is “When specifically error X happens, do Y”, writing service code that is tightly coupled to X being thrown is absolutely okay, as long as it’s implemented next to all the other business logic.
So, let’s switch from re-throwing exceptions to returning values. To do that, we need a datatype that denotes two situations:
- the code finished without issues, and the result is some
value: T
, and - an oops happened
The first thing that comes to mind is Optional<T>
, which is semantically equivalent to T?
, however this is not rich enough for us. Nullables/optionals are fine for representing success, but if something goes wrong, we probably want to encode some information about what went wrong - the 'oops instance', so to speak. And nullables/optionals only have the ability to communicate "something" vs "nothing". What we want is Success(value: T)
vs Failure(oops: Throwable)
.
So…let’s do exactly that! Let’s create a type Result<out T>
and two subclasses Success<T>(val value: T): Result<T>
and Failure(val error: Throwable) : Result<Nothing>
and use those to wrap all of our calculations.
//sampleStart sealed interface Result<out T> data class Success<T>(val value: T): Result<T> data class Failure(val error: Throwable) : Result<Nothing> // Before fun retrieve(id: Long): Product = try { productRepository.retrieveById(id) } catch (e: ObjectNotFoundException) { throw ProductIdDoesntExist(e) } // after fun retrieve(id: Long): Result<Product> = try { Success(productRepository.retrieveById(id)) } catch (e: Throwable) { Failure(e) } //sampleEnd
Hmmm…could we do better? Yes we could:
//sampleStart fun <T> runCatching(block: () -> T): Result<T> = try { Success(block()) } catch (e: Throwable) { Failure(e) } fun retrieve(id: Long): Result<Product> = runCatching { productRepository.retrieveById(id) } //sampleEnd
By introducing two simple types and a function, we were able to:
- recover normal execution flow
- get rid of spooky “action-at-a-distance” exception handlers
- write code that is shorter, cleaner, and almost as simple as code where we completely ignore exceptions
- deal with all possible errors, now and in the future
- be explicit that this operation can fail, and use types to force calling code to deal with these failures (incidentally, this is what checked exceptions were trying, and mostly failing, to do)
However, most importantly, we have shifted our mindset. We no longer view exceptions as control flow constructs, we view them as simple carriers of information — basically data classes. We don’t immediately feel the need to translate or chain them, unless there is an actual need to provide more information or react to them — for the most part, the type won’t matter.
The benefit of this approach gets multiplied when you start dealing with libraries that represent errors in different ways.
Here’s an example:
//sampleStart // Returns an Int, throwing SomeException if it cannot do so fun libFun1(): Int { // ... } // Returns an Int if it is available, or an empty optional if it is not available. Throws SomeOtherException if // argument is invalid fun libFun2(argument: Int): Optional<Int> { // ... } //sampleEnd
Given those functions, we are tasked with creating ratioOfLibFuns()
which returns the Int
ratio of both library functions, and deals with errors appropriately. In this specific scenario, if the Int
returned by libFun2
is missing, it means that the user forgot to do something, and we want to convey this information somehow.
Here’s how we would probably implement this up until now:
//sampleStart fun ratioOfLibFuns_oldWay(argument: Int): Int { try { val int1 = libFun1() val int2 = libFun2(argument).orElseThrow { UserRatioException("Int2 was not entered by user") } if (int2 != 0) { return int1/int2 } else { throw GenericRatioException("Int2 is 0!") } } catch(e: SomeException) { throw GenericRatioException("Int2 is not available", e) } catch(e: SomeOtherException) { throw GenericRatioException("Argument to libFun2 is invalid!", e) } } //sampleEnd
Ugh, what a mess. Thankfully, using what we just discovered, we can do better:
//sampleStart fun ratioOfLibFuns_newWay(argument: Int): Result<Int> = runCatching { val int1 = libFun1() val int2 = libFun2(argument).orElseThrow { UserRatioException("Int2 was not entered by user") } int1 / int2 } //sampleEnd
By introducing a super simple type, we managed to make to code about 4x shorter, increase its readability and maintainability, and become explicit about the fact that it can fail.
In fact, we could go further — we could combine this approach with what we learned about strongly typed illegal states, and avoid using UserRatioException
completely:
//sampleStart sealed interface Ratio data class RatioOf(val result: Int): Ratio object Int2Missing: Ratio fun ratioOfLibFuns_newWay(argument: Int): Result<Ratio> = runCatching { val int1 = libFun1() libFun2(argument) .map<Ratio> { int2 -> RatioOf(int1 / int2) } .orElse(Int2Missing) } //sampleEnd
Notice how much information you gain just by reading the signature of the function: it returns a Ratio
instance, which recognizes a single type of business error (Int2Missing
), and is wrapped in Result
, which signifies that the method can fail unexpectedly. Without even looking at the implementation, you immediately know about all the things that can possibly happen in this method.
This is one of the incredibly powerful consequences of using types which have implicit meaning/behavior associated with them. You actually know this very well, but might not realize it. Think about it — Optional
means a presence or absence of a value, List
means multiple values, Future
is a value which will exist at some point in time, Function
is a value that can be produced, Result
is a value or a failure, and so on.
This is actually one of the core principles of actual functional programming — using these datatypes, as well as many other ones such as Either
, Writer
, State
, Eval
and many, many others, to represent behavior in a program, and building what we need as a composition of these fundamental, provably correct behaviors. This topic is far beyond the scope of this article, but I thought it was worth mentioning that there was a close connection with what we’re doing here with Result
.
It turns out that the Result
hierarchy and the runCatching
method are already part of the standard library. In the next article, we’ll explore what’s included in the standard library more closely.