DEV Community

swyx
swyx

Posted on • Edited on • Originally published at swyx.io

Errors Are Not Exceptions

listen to me explain this in a podcast

TL;DR

  • Errors are unrecoverable, Exceptions are routine.
  • Most languages (Java, PHP) build the distinction into the language. Yet others (Go) name them the other way round. Some languages (JavaScript, Python) treat them as synonyms.
  • No matter what way you name things, you should handle Errors and Exceptions separately in your code or bad things happen.

Because I started out in JS/Python and then went to Go, without touching Java, getting this distinction right took me a few hours of thinking and research. It's not self-evident!

Context

If you've ever thrown an error in a function expecting its invoker to catch it, you're doing it wrong.

Ok, I'll admit I'm just hamming up a mere opinion for a more eye-catching opening. But I do feel strongly about this so...

I was recently reminded of this while going through the Go FAQ and being reminded that Go does not have exceptions.

What? If you've always coded in a language that has exceptions, this ought to jump out at you.

Go does not have try or catch. Despite those language constructs existing for decades, Go chose to have Defer, Panic, and Recover instead. By convention and design, Go encodes an extremely strong opinion that errors should be returned, not thrown.

But Why

Relying on exception handling to handle errors either leads to convoluted code or unhandled errors.

This kind of code is common in JavaScript:

function trySomethingRisky(str) {
        if (!isValid(str)) throw new Error('invalid string!')
        return "success!"
}

function main() {
    try {
        return trySomethingRisky(prompt('enter valid name'))
    } catch (err) {
        if (err instanceof Error) {
            // handle exceptions
        } else {
            // handle errors
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If you're thinking that you don't write this sort of code very often, you're probably not thinking through your failure modes enough.

  • JavaScript doesn't have a native way to indicate whether a function can throw, if you invoke it. So you cannot lint against it — you must either pay this cost earlier in manual code review or later in bug reports.
  • An innocent fs.readFileSync call can bring down a whole server (or memory-leak descriptors) given the wrong string.
  • Promise calls without a catch in the browser will simply log silent errors (a terrible user experience).

The more function and module boundaries you cross, the more you need to think about defensively adding try/ catch and handling the gamut of errors that can happen, and the harder it is to trace where errors begin and where they are handled.

Aside - some authors like this Redditor and Matt Warren make a performance driven argument for encouraging developers to not overuse exceptions. Exceptions involve a memory and compute intensive stack search. This matters at scale, but most of us will never run into this so I choose not to make a big deal out of it.

Errors vs Exceptions

Let's attempt a definition:

  • Exceptions are expected failures, which we should recover from.
  • Errors are unexpected failures. By definition, we cannot recover elegantly from unexpected failures.

You might notice the ironic inversion - it is errors that are "exceptional", while exceptions are routine. This was very confusing to your humble author.

This is no doubt due to the fact that JavaScript, Python, and other languages treat errors and exceptions as synonyms. So we throw Errors when we really mean to throw exceptions.

PHP and Java seem to have this difference baked into the language.

To make things extra confusing, Go uses error where other languages would call exceptions, and relies on panic to "throw" what other languages would call errors.

Notes: Chris Krycho observes that you can use Rust, F#, and Elm's Result in a similar way, and Haskell's Either.

Exception Handling vs Error Checking

The realization that we need different paradigms for handling errors and exceptions is of course not new. Wikipedia's entry on Exception Handling quotes Tony Hoare (creator of QuickSort, CSP and the null reference) saying that exception handling is "dangerous. Do not allow this language in its present state to be used in applications where reliability is critical."

That was said in 1980, yet here we are 40 years later.

The alternative to exception handling is error checking.

Error Checking in Go

Note: Go seems to have a strong opinion that "Errors" are routine and Exceptions (not formally named, but used in panic) are Exceptional - in direct opposition to other languages. I have opted to use Go-native terminology - minimizing confusion locally at the cost of increasing global confusion.

Errors are values in Go — made to be passed, not thrown. Go's FAQ is worth quoting here:

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

When something goes wrong, your default choice should be using multi-value returns to report errors:

i, err := strconv.Atoi("42")
if err != nil {
    fmt.Printf("couldn't convert number: %v\n", err)
    return
}
fmt.Println("Converted integer:", i)
Enter fullscreen mode Exit fullscreen mode

This pattern would be subject to the same weaknesses I outlined above, except for the fact that Go will refuse to compile if you 1) don't assign all returned values at the callsite or 2) don't use values that you assign. These two rules combined guide you to handle all errors explicitly near their origin.

Exceptions still have a place — but the language reminds you how rarely you should use it, by calling it panic(). You can still recover() and treat it like a backdoor try/ catch in Go, but you will get judgy looks from all Gophers.

Error Checking in Node

JavaScript lacks the 2 features I mention above to force you to handle errors.

To work around this and gently nudge you, Node uses error-first callbacks:

const fs = require('fs');

function errorFirstCallback(err, data) {
  if (err) {
    console.error('There was an error', err);
    return;
  }
  console.log(data);
}

fs.readFile('/some/file/that/does-not-exist', errorFirstCallback);
fs.readFile('/some/file/that/does-exist', errorFirstCallback);
Enter fullscreen mode Exit fullscreen mode

This pattern is idiomatic in most Node libraries, but the further we get away from Node, the more we tend to forget that there is an alternative to throwing errors, when writing libraries and app code.

Lastly, it is tempting to promisify those callbacks:

const util = require('util');
const fs = require('fs');

const stat = util.promisify(fs.stat); // i am using fs.stat here, but could be any error-first-callback userland function

// assuming top-level await
try {
    const stats = await stat('.')
    // do something with stats
} catch (err) {
    // handle errors
}
Enter fullscreen mode Exit fullscreen mode

And we are right back where we started - being able to fling errors and exceptions arbitrarily high up and having to handle both in the same place.

Other Reads

Thanks to Charlie You and Robin Cussol for reviewing drafts of this post.

Top comments (18)

Collapse
 
lionelrowe profile image
lionel-rowe • Edited

In JavaScript, Errors are just another type of object. So you can return new Error('message') instead of throwing it, and TypeScript will modify the return type accordingly.

On the flipside, you can throw something that's not an Error if you really want. throw 'just a string', throw [error1, error2], etc. Though some linter configurations might complain at you if you do this.

Collapse
 
macsikora profile image
Pragmatic Maciej • Edited

Good read.
But very one sided. Coin has two sides, there are languages which choose to have try catch and those which treat error flow as just one of flows, not a special language thing. And FP languages exactly do that(Elm or Haskell,Ocaml) instead of pair result, error which you have shown in Go there is Either (most probably you know what it is so will not continue). So Go really go in the se direction here.

So or we just use error as one of possible returns procedure/function can have or error have own special execution by try catch which skips call stack.

What problem I see in returning errors is that there can be many levels of such passing error around until we find a place which deals with that finally. With try catch we can just jump to the place where we handle it.

I am not sure that saying that Error and Exception are different things. Fact that you use some language feature which allows to jump doesn't mean that you cannot make proper error handling from that. I see try catch as just language construct. In many situation we just don't know what to do with some errors, there is just no alternative flow, it failed and game over so we throw and catch in the place where we say game over instead of juggling around and pretending that we know what to do.

Some time ago I also thought error as just return value is always the way to go. Now I see the trade-off in both solutions. But if language design bets on try/catch I would use it.

Collapse
 
shalvah profile image
Shalvah

What problem I see in returning errors is that there can be many levels of such passing error around until we find a place which deals with that finally. With try catch we can just jump to the place where we handle it.

I love this too about exceptions. When my code mixes the actual logic with the error handling (if err != nil and the like), it gets very difficult to read. I like that try/catch lets the happy path flow naturally and you handle exceptions in one place.

Of course, you need to remember to handle the errors, but that's a price I'm willing to pay, rather than passing error objects around.

Collapse
 
shalvah profile image
Shalvah • Edited

Still reading the article, but this kept bugging me, so I had to comment.😁

Errors are unrecoverable, Exceptions are routine.

Isn't this the other way around? I remember someone (I forget who) saying, "Exceptions should be reserved for exceptional circumstances." As in, if something that's routine happens, you shouldn't throw an exception. Reserve exceptions for situations that shouldn't happen.

I don't know if I'm all in on that. It's something I'm still mulling on. Just had to say it because it seems like you see it differently. Maybe it's just a nomenclature issue.😅

Collapse
 
shalvah profile image
Shalvah

Another thing is: I feel like we may be co-opting language terminology and constructs into our programming model, rather than the other way around. For instance, just because a language says "We don't have exceptions" doesn't mean that's true. If it has some error-handling mechanism that acts identically to exceptions, in most languages, then those are exceptions.

I think distinguishing between "error" and "exception" is a doomed task, because those are defined by the language. I often use them interchangeably. Maybe we should use language-agnostic terms like "failures" vs "errors", where:

  • failure = this operation failed for an anticipated reason
  • error = this operation failed for an unexpected reason

In summary: This shit is confusing. 😂

Collapse
 
devinrhode2 profile image
Devin Rhode

Created a wrapper function to do this:

const [thing, error] = safeCall(getThing)
Enter fullscreen mode Exit fullscreen mode

gist.github.com/devinrhode2/254255...

Collapse
 
devinrhode2 profile image
Devin Rhode • Edited

I think, this safeCall is nice when you are calling external libraries/functions that are hard to change to return [thing, error].

I am currently converting a 4-5 throw statements to:

return [
  undefined,
  new Error('asdf')
]
Enter fullscreen mode Exit fullscreen mode

The nice thing about this, is that the consumer/caller does not need to check:

const maybeThing = dangerousCall();
if (maybeThing instanceof Error) {}
Enter fullscreen mode Exit fullscreen mode

They can simply:

const [thing, error] = dangerousCall();
if (error) {}
Enter fullscreen mode Exit fullscreen mode

However, once we destructure, the "Either or ness" disappears. Type of thing and error are both Thing | undefined and Error | undefined. While basics like this work

if (error) {
  // TS knows that `thing` is defined
}
Enter fullscreen mode Exit fullscreen mode

There are instances where you want to pass the whole tuple, maybeThing.

I'm curious what a good variable name is for this maybeThing.

Collapse
 
devinrhode2 profile image
Devin Rhode • Edited

Update: I recommend using an object style return. The array brackets are cute, but less useful. Positional args are fragile, and while this is only two, if you want to give a special error code or warning, then a simple new Error('asdf') can't represent either of these two (at least not very clearly).

So you may typically do:

return { error: new Error('asdf') }
Enter fullscreen mode Exit fullscreen mode

but you may occassionally:

return {
  warning: 'Update skipped. Provided data is the same.',
  errorCode: 'update_skipped',
}
Enter fullscreen mode Exit fullscreen mode

Note to self: I don't think I have any code that actually uses this idea. Was just a pure idea.

Collapse
 
stereobooster profile image
stereobooster

You may be interested in how Pony handles errors

An error is raised with the command error. At any point, the code may decide to declare an error has occurred. Code execution halts at that point, and the call chain is unwound until the nearest enclosing error handler is found. This is all checked at compile time so errors cannot cause the whole program to crash.

They look like exceptions (try/catch), but they are not. Because you can't actually use exception as value which carries message.

Also this maybe interesting (this is about Go): Rob Pike Reinvented Monads

In JS/TS we also have errors as values (idea copied from Monads in Haskell): io-ts.

Collapse
 
olexandrpopov profile image
Oleksandr Popov

You also might be interested in Either monad from fp-ts library.

Collapse
 
devinrhode2 profile image
Devin Rhode

@olexandrpopov, your Either link is broken, new url is:
rlee.dev/practical-guide-to-fp-ts-...

Thread Thread
 
devinrhode2 profile image
Devin Rhode

The import cost of this is insane. More code than an mui Box component!
Image description

Collapse
 
loujaybee profile image
Lou (🚀 Open Up The Cloud ☁️)

A nice article! Thanks for writing up!

My approach to error handling in JS I detailed here:

thedevcoach.co.uk/error-handling-j...

But the TL;DR: is:

  • Wrap all behavior into a try/catch
  • Wrap any nested code into a try/catch where you think necessary, and throw an explicit error for things which you're handling
  • Anything that comes through as an unhandled exception then becomes a ticket, and the error handling is put in place to ensure the unhandled exception is put in place.

Doing this means you never miss any unhandled errors, and gives a very clear pattern for error handling in JS.

But... I prefer go's way of doing things, which becomes essentially the same, but without the high level try/catch boilerplate, etc.

Collapse
 
eljayadobe profile image
Eljay-Adobe

Joe Duffy wrote an excellent lengthy article on Midori's Error Model. The entire article is applicable to this topic, but in particular the Bugs Aren't Recoverable Errors! section.

Collapse
 
michelemauro profile image
michelemauro

As you can see in the comments, this is a very confusing issue. More good articles like this are needed.

Collapse
 
devinrhode2 profile image
Devin Rhode

Yeah, I'm trying to hash out a good pattern: dev.to/devinrhode2/comment/22912

Collapse
 
ravavyr profile image
Ravavyr

Honestly, the worst thing I see is people using Try/Catch blocks to wrap a dozen potential issues and let it just blindly catch the failures regardless of how they interact with the rest of the system.

Most sites don't have one condition to check, they have half a dozen or more per feature, and ideally you check each one separately to validate the data and then TRY to process it and Catch any failures that result from that [eg. the "should never happen" conditions], while also handling the response data correctly to display any data errors to the users.

Personally I avoid using Try/Catch as much as I can and instead validate variables using conditionals and either manually logging errors or generating error messages to the user as needed. The manual logging is rare as again if you're catching errors like that, more often than not the only reason you'd log it is to see how someone is trying to abuse your code since you already know what the issue is and it's not really a problem that ever happens in a normal use-case. The best approach to someone trying to break into your system is to respond with a vague non-issue like "invalid token"...whether you use a token or not, the would-be hacker doesn't deserver a real response.

Exceptions can happen, but basic Errors should never be present in production code. If there are basic Errors in production it means you didn't test your work by actually running it/using it, I'm not talking about using the stupid linters that complain about semi-colons and 80 characters per line limits. I mean, really test your work to know it works correctly. You have [or have been given] requirements, if you don't, demand or write them, and meet them.

Collapse
 
seanmclem profile image
Seanmclem

What's your vs code theme?