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#:
try { doABarrelRoll();} catch (Exception e) { // handle error}
Or a try/except
in 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:
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:
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.
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:
match do_a_barrel_roll() { Ok(success) => /*handle success*/, Err(error) => /*handle error*/}
Where the function signature looks something like this:
fn do_a_barrel_roll() -> Result<i32, String>
And the definition of the Result
type is:
enum Result<T, E> { Ok(T), Err(E),}
Or in Kotlin:
when (val result = doABarrelRoll()) { is Ok -> /*handle success*/ is Err -> /*handle error*/}
Or 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.