Error Handling Patterns: Theory vs Reality
I thought handling errors by the book would make my code beautiful. Reality was try-catch hell.
I had no idea where to put try-catch
When I first learned error handling as a junior, I wrapped every function in try-catch. Literally every function. If 10 functions called each other in a chain, that was 10 layers of try-catch. Errors got caught, sure, but tracking down where they originated was impossible.
Even with console.log, the same error printed 10 times. Each layer caught it and re-threw it, so the stack trace was a jumbled mess.
Catching once at the top -- a late realization
If an error can't be handled where it occurs, it should bubble up. And you only need to catch it once at the level where it can actually be dealt with. Understanding this principle cleaned up my code dramatically.
I built a global error handler as Express middleware, and business logic just throws. Code volume dropped by 30%.
(Though this wasn't a silver bullet either.)
Custom error classes -- when to make them?
At first, I didn't create custom errors. Just throw new Error("User not found"). But looking at the error message alone, I couldn't tell whether the API should return a 404 or a 400.
So I created custom errors: NotFoundError, ValidationError, UnauthorizedError. The global error handler mapped error types to HTTP status codes. This worked quite well.
But then the custom errors ballooned to 14. PaymentFailedError, PaymentTimeoutError, PaymentInvalidAmountError... Creating error classes became a job in itself.
Error message management in practice
The error message shown to users and the one developers see need to be different. Show "Internal Server Error" to users and you get a CS firestorm. Show detailed errors and you've got a security issue.
I separated error objects into userMessage and debugMessage. Clean in theory. In practice, we kept deploying without setting userMessage. Users saw undefined on screen twice.
(undefined. As an error message. Shipped to users.)
The async error swamp
If you don't catch a Promise, you get an unhandled rejection. In Node.js, this can kill the process. I added a process.on('unhandledRejection', ...) handler, but that's a last line of defense -- you shouldn't code relying on it.
With async/await, putting multiple awaits inside a single try-catch means you can't tell which await threw. Wrap each one individually and you're back in try-catch hell. I still don't have a clean answer for this dilemma.
Where I've landed
Only catch errors where you can actually recover. Let everything else bubble up. Keep custom errors to 5 or fewer, based on HTTP status codes. Log errors in exactly one place. Following just these rules covers most situations.
Honestly, I've never seen perfect error handling. Eventually, an unexpected error hits, and you patch the code. That's just how it is.