Terror Handling
The Happy path of writing software is usually the easiest part and coincidentally is the the thing focused on most during estimation. A lack of thought behind error handling can have big consequences on the long term health of a code base.
I am going to contrast on the 3 main methods of handling errors that I have come across, exceptions, product types (i.e tuples) and sum types (Try, Either, et al).
The problem with exceptions
On the face of it exceptions seem convenient but most people understand that you need to apply a lot of good practice for them not to be a pain. There is countless literature about the misuses of exceptions, from Pokemon exception handling to using exceptions as control flow.
The best use-case for exceptions is when you “know” it can’t be recovered from. The problem is exceptions tend to be conflated with normal errors that you do want to recover from.
When you use exceptions like this then it impacts code re-use. By throwing exceptions you are losing referential transparency, which means you cannot trust the type system; getFoo
might not return a Foo
for all inputs (it is not a total function). This means that reasoning your code becomes more difficult (because you need to check the source to see the real behaviour) and it is harder to simply plug functions together.
Product types to the rescue?
The debate around error handling is fairly prominent in the Go community, mainly because it doesn’t really* have exceptions.
The language supports tuples so the convention is to simply return the error to the caller if there is one.
package main
import "fmt"
func printer(f func() (string, error)){
result, err := f()
if err != nil {
fmt.Println("Fail!", err)
} else {
fmt.Println("Success!", result)
}
}
func niceFunc() (string, error){
return "Whoop", nil
}
func main() {
printer(niceFunc)
}
In this case niceFunc
is referentially transparent and the higher order function lets us know that it wants a function which could return an error and will act on it.
This explicitness means you don’t have to look into the implementations of functions to check for exceptions being thrown and the compiler (and tooling) will complain if you try and pretend a function could never return an error.
There is a better way
In simple examples it doesn’t seem so bad, but a lot of Go code is littered with these kind of checks. The main problem is that it is very difficult to compose potentially failing functions into new functionality.
package main
import "fmt"
func func1() (string, error){
return "Whoop", nil
}
func func2(input string) (string, error){
return "", fmt.Errorf("Bwahahaha")
}
func func3(input string) (string, error){
return "Yippee", nil
}
func main() {
// We want to chain func1, func2, func3, bailing if we get an error
res1, err := func1()
if err != nil{
return
}
res2, err := func2(res1)
if err != nil{
return
}
finalResult, err := func3(res2)
if err != nil{
return
}
fmt.Println(finalResult)
}
Scala (and others) lets you write sum-types which allow you to encapsulate these very common computations in the type system in such a way that you can be explicit and still write your code in a declarative and convenient way.
case class DomainError(context: String, error: String)
// this is using scalaz's either type
def func1(): DomainError \/ String = ??? // so in this case it can return either a DomainError or a String
def func2(x: String): DomainError \/ String = ???
def func3(x: String): DomainError \/ String = ???
// see how easy it is to compose functions
def functionsComposed: \/[DomainError, String] = func1 flatMap func2 flatMap func3
// take our newly composed function and pattern match
def process = functionsComposed match{
case -\/(err:DomainError) => // do something with error
case \/-(result: String) => // do something with result
}
As you can see, i am declaratively gluing the functions which could fail together. If they fail at any point then the result becomes a DomainError
and doesn’t call the following functions.
It’s important to note that the respective functions don’t “care” about this composition, i didn’t have to do anything special other than declare that there might be an error using the type system.
This approach has the referential transparency of Go whilst being more convenient and declarative
Summary
When you raise errors to a first class citizen in your code by asking the compiler for help, you improve the re-usability and understandability of your code; not to mention it will be more robust as you won’t be having uncaught exceptions flying around.
As your type system becomes more expressive the perceived convenience of exceptions over explicitness disappears.