Try, Catch, Repeat

🗓️ • ⏳ 6 min

Quick overview of different ways to handle errors in software development.

Error Codes

The simplest and oldest way to handle errors. You would usually return an integer or null.

Unix shells famously follow this convention by returning either 0 for success or 1-255 in case of error.
This way, not only can you inform the caller of an error, but also specify the kind or severity of it.

In C, -1 is commonly returned to indicate an error. Whenever the return type cannot be constrained to an int, NULL is returned instead.

Of course there’s an issue here: it’s just a matter of convention (what code should I use?).
There is no specific error type or compile time error that tells you how to handle the error.
In fact, the caller might very well just ignore it!

Plus, this approach limits the signature and design of a function call.
What if I try to fetch a user from a DB and fail to connect? I can return NULL, but then what should I do when I just don’t find the user? Also NULL?

Exceptions

A much more popular approach is to “throw an exception” or “raise an error”, depending on the specific language jargon.

This relies on the language providing some construct to create a separate, conditional execution flow.
Sort of like an if/else statement, only in this case, the if branch continues normal execution while the else branch unwinds the call stack halting the rest of the program.

This takes the form of a try/catch in Java or C#:

java
try {
doABarrelRoll();
} catch (Exception e) {
// handle error
}

Or a try/except in Python:

python
try:
do_a_barrel_roll()
except Exception as e:
# handle error

More often than it should, this gets used as a clever way to not pay attention to errors: I can now throw them wherever I want, as long as there’s a try/catch somewhere up the stack I’m golden.

As the call stack grows and the number of throws grows with it, following this conditional logic gets incredibly complicated, especially when these Exceptions/Errors are not explicitly declared by the type system at compile time (looking at you C#).

Callbacks

In functional style languages and async heavy programming, functions are passed to the main function being called, which will then be run whenever the main function finishes.
These are, of course, callback functions, as in they will be called back at a later point in time.

Typically found in and popularized by pre-promises JavaScript, it looks something like this:

javascript
doABarrelRoll((err, result) => {
if (err) {
// handle error
} else {
// use result
}
});

Of course, nothing stops you from calling other functions from within the callback function. And more functions in the callbacks for those functions…
Welcome to the infamous Callback Hell:

javascript
getUser(function(user) {
getPosts(user.id, function(posts) {
getComments(posts[0].id, function(comments) {
sendNotification(user.email, function(response) {
console.log('Notification sent:', response);
}, function(error) {
console.error('Failed to send notification:', error);
});
}, function(error) {
console.error('Failed to get comments:', error);
});
}, function(error) {
console.error('Failed to get posts:', error);
});
}, function(error) {
console.error('Failed to get user:', error);
});

Of course, this is mostly seen in legacy codebases. JavaScript (thankfully) now uses async/await and try/catch, which is far from perfect, but miles better than this.

Returning the Error

As an alternative to error codes, some languages (most notably Golang) allow for multiple return values.

go
res, err := doABarrelRoll()
if err != nil {
// handle error
}

This way, the error doesn’t interfere with the actual result of the operation, and both can be independently typed and checked accordingly.

Pretty nifty, but also quite verbose: the code gets littered with if err != nil everywhere.
Then again, at least with this approach devs are forced to confront and handle errors, which might be tedious but also necessary.

Also of note, nothing is stopping you from writing a function that returns User, Post instead of User, Error, so there’s a whole other way to shoot yourself in the foot here.
In fact, some languages simulate this pattern by wrapping the return values in a list, so that one “value” is return containing n values.
Complexity here can get out of hand real fast.

Returning a Wrapper

Similar in spirit from that list approach, we can be more explicit about the return values by wrapping them in a dedicated type.

A Result type is often used for this, such as in Rust:

rust
match do_a_barrel_roll() {
Ok(success) => /*handle success*/,
Err(error) => /*handle error*/
}

Where the function signature looks something like this:

rust
fn do_a_barrel_roll() -> Result<i32, String>

And the definition of the Result type is:

rust
enum Result<T, E> {
Ok(T),
Err(E),
}

Or in Kotlin:

kotlin
when (val result = doABarrelRoll()) {
is Ok -> /*handle success*/
is Err -> /*handle error*/
}

Or Haskell:

haskell
let result = do_a_barrel_roll
case result of
Right val -> -- handle success
Left err -> -- handle error

Notice how Haskell is different in that instead of Ok and Err it has Right and Left.

This is because, instead of having a Result type like Rust and Kotlin, it uses Either as its return value.
In fact, Result is little more than a specialized version of Either, where the semantics more clearly indicate the meaning of each possible value.

This paradigm is kinda hard to get accustomed to, as it forces you to think about the error just as much as you do about the happy path. Which is awesome IMO, but needs some getting used to if you are used to the “throw an error and hope for the best” school of thought.

Also, this requires either a language with a decently strong type system or very diligent developers.

Conclusions

At the end of the day, the language you use often defines the error handling approach for you.

That being said, as long as the rest of the team is on board, I would suggest playing around with different paradigm from the ones your stack defaults to.

Not everyone finds the same error handling system equally intuitive, and some contexts are just better suited for some paradigms than others.


Other posts you might like