JS aside, I recently tried my very best to introduce proper logging and error handling to otherwise "look ma, no handlebars" codebase.
Call it a thought experiment. We start with a clean implementation that satisfies requirements. It makes a bold assumption that every star in the universe will align to help us achieve to goal.
Now we add logging and error handling.
Despite my best intentions and years of experience, starting with clean code, the outcome was a complete mess.
It brings back memories when in 2006 I was implementing deep linking for Wikia. I started with a "true to the documention" implemention which was roughly 10 lines of code. After handling all edge cases and browser incompatibilites I ended up with a whooping 400 lines.
Doing exactly the same as the original lines did, but cross compatible.
Most of these proposals miss the point. Errors need a useful taxonomy, based on what to do about them. The question is what do you do with an error after you caught it. A breakdown like this is needed:
- Program is broken. Probably need to abort program. Example: subscript out of range.
- Data from an external source is corrupted. Probably need to unwind transaction but program can continue. Example: bad UTF-8 string from input.
- Connection to external device or network reports a problem.
-- Retryable. Wait and try again a few times. Example: HTTP 5xx errors.
-- Non-retryable. Give up now. Example: HTTP 4xx errors.
Python 2 came close to that, but the hierarchy for Python 3 was worse. They tried; all errors are subclasses of a standard error hierarchy, but it doesn't break down well into what's retryable and what isn't.
Like most things in C++, I wish the default was `nothrow`, and you added throw for a function that throws. There's so many functions that don't throw, but aren't marked `nothrow`.
In my experience I've used exceptions for things that really should never fail, and optional for things that are more likely to.
Returning error codes was actually the first approach to error handling. Exceptions (try/catch) became widespread much later. The article got it backwards calling try/catch "traditional" and Go's approach "modern".
Errors as values approach suffers similar problem as async/await - it's leaky. Once the function is altered to possibly return an error, its signature changes and every caller needs to be updated (potentially all the way to the main(), if error is not handled before that).
This approach is great when:
* program requirements are clear
* correctness is more important than prototyping speed, because every error has to be handled
* no need for concise stack trace, which would require additional layer above simple tuples
* language itself has a great support for binding and mapping values, e.g. first class monads or a bind operator
Good job by the author on acknowledging that this error handling approach is not a solver bullet and has tradeoffs.
Well, IIUC, Java had (and still has) something called “checked exceptions”, but people have, by and large, elected to not use those kind of exceptions, since it makes the rest of the code balloon out with enormous lists of exceptions, each of which must be changed when some library at the bottom of the stack changes slightly.
The biggest annoyance with Java checked exceptions IME is that it’s impossible to define a method type that’s generic over the type of exception it throws.
Checked exceptions should indicate conditions that are expected to be handled by the caller. If a method is throwing a laundry list of checked exceptions then something went wrong in the design of that method’s interface.
I think it's fair to say that having some sort of syntactically lightweight sum or union type facility makes this way nicer than anything Java ever had -- subclassing isn't really a solution, because you often want something like:
TypeScript's type system would hypothetically make this pretty nice if there were a common Result type with compiler support.
Rust needs a bit more boilerplate to declare FooError, but the ? syntax automatically calling into(), and into() being free to rearrange errors it bubbles up really help a lot too.
The big problem with Java's checked exceptions was that you need to list all the exceptions on every function, every time.
> each of which must be changed when some library at the bottom of the stack changes slightly.
I hate checked exceptions too, but in fairness to them this specific problem can be handled by intermediate code throwing its own exceptions rather than allowing the lower-level ones to bubble up.
In Go (which uses error values instead) the pattern (if one doesn’t go all the way to defining a new error type) is typically to do:
if err := doSomething(…); err != nil {
return fmt.Errorf("couldn’t do something: %w", err)
}
which returns a new error which wraps the original one (and can be unwrapped to get it).
A similar pattern could be used in languages with checked exceptions.
I love libraries that does a simple check and signals that it "failed" with ThingWasNotTrueException.
In surprising twist: Java has ConcurrentModificationException. And, to counter its own culture of exception misuse, the docs have a stern reminder that this exception is supposed to be thrown when there are bugs. You are not supposed to use it to, I dunno, iterate over the collection and bail out (control flow) based on getting this exception.
JS aside, I recently tried my very best to introduce proper logging and error handling to otherwise "look ma, no handlebars" codebase.
Call it a thought experiment. We start with a clean implementation that satisfies requirements. It makes a bold assumption that every star in the universe will align to help us achieve to goal.
Now we add logging and error handling.
Despite my best intentions and years of experience, starting with clean code, the outcome was a complete mess.
It brings back memories when in 2006 I was implementing deep linking for Wikia. I started with a "true to the documention" implemention which was roughly 10 lines of code. After handling all edge cases and browser incompatibilites I ended up with a whooping 400 lines.
Doing exactly the same as the original lines did, but cross compatible.
I guess I’ll ask, did you try using exceptions?
Handlebars like on a bike, or like the templating language?
Most of these proposals miss the point. Errors need a useful taxonomy, based on what to do about them. The question is what do you do with an error after you caught it. A breakdown like this is needed:
- Program is broken. Probably need to abort program. Example: subscript out of range.
- Data from an external source is corrupted. Probably need to unwind transaction but program can continue. Example: bad UTF-8 string from input.
- Connection to external device or network reports a problem.
-- Retryable. Wait and try again a few times. Example: HTTP 5xx errors.
-- Non-retryable. Give up now. Example: HTTP 4xx errors.
Python 2 came close to that, but the hierarchy for Python 3 was worse. They tried; all errors are subclasses of a standard error hierarchy, but it doesn't break down well into what's retryable and what isn't.
Rust never got this right, even with Anyhow.
Like most things in C++, I wish the default was `nothrow`, and you added throw for a function that throws. There's so many functions that don't throw, but aren't marked `nothrow`.
In my experience I've used exceptions for things that really should never fail, and optional for things that are more likely to.
This is called the "result pattern". I would not call this a novel concept. In C# we use this: https://github.com/ardalis/Result
Yes, I stopped reading at:
> The most common approach is the traditional try/catch method.
Weird to stop reading at a statement that is factually true.
Returning error codes was actually the first approach to error handling. Exceptions (try/catch) became widespread much later. The article got it backwards calling try/catch "traditional" and Go's approach "modern".
Somewhat related, from March: https://news.ycombinator.com/item?id=43297574
Errors as values approach suffers similar problem as async/await - it's leaky. Once the function is altered to possibly return an error, its signature changes and every caller needs to be updated (potentially all the way to the main(), if error is not handled before that).
This approach is great when:
* program requirements are clear
* correctness is more important than prototyping speed, because every error has to be handled
* no need for concise stack trace, which would require additional layer above simple tuples
* language itself has a great support for binding and mapping values, e.g. first class monads or a bind operator
Good job by the author on acknowledging that this error handling approach is not a solver bullet and has tradeoffs.
It’s only leaky if you do not consider failure cases to be as equally intrinsic to an interface’s definition as its happy-path return value :-)
the blog lacks the review of one critical player effect.ts https://effect.website/docs/error-management/two-error-types...
Very balanced post thank you. Often these posts tout an approach, and never consider downsides.
> Lack of Type System Integration
Well, IIUC, Java had (and still has) something called “checked exceptions”, but people have, by and large, elected to not use those kind of exceptions, since it makes the rest of the code balloon out with enormous lists of exceptions, each of which must be changed when some library at the bottom of the stack changes slightly.
The biggest annoyance with Java checked exceptions IME is that it’s impossible to define a method type that’s generic over the type of exception it throws.
Checked exceptions should indicate conditions that are expected to be handled by the caller. If a method is throwing a laundry list of checked exceptions then something went wrong in the design of that method’s interface.
I think it's fair to say that having some sort of syntactically lightweight sum or union type facility makes this way nicer than anything Java ever had -- subclassing isn't really a solution, because you often want something like:
TypeScript's type system would hypothetically make this pretty nice if there were a common Result type with compiler support.Rust needs a bit more boilerplate to declare FooError, but the ? syntax automatically calling into(), and into() being free to rearrange errors it bubbles up really help a lot too.
The big problem with Java's checked exceptions was that you need to list all the exceptions on every function, every time.
Java's sealed interfaces enable typing errors.
https://blogs.oracle.com/javamagazine/post/java-sealed-class...
Although syntactically lightweight it is the opposite of.
I agree; Java is constitutionally incapable of being lightweight. I much prefer Typescript's union syntax. I'm glad Python copied it.
> each of which must be changed when some library at the bottom of the stack changes slightly.
I hate checked exceptions too, but in fairness to them this specific problem can be handled by intermediate code throwing its own exceptions rather than allowing the lower-level ones to bubble up.
In Go (which uses error values instead) the pattern (if one doesn’t go all the way to defining a new error type) is typically to do:
which returns a new error which wraps the original one (and can be unwrapped to get it).A similar pattern could be used in languages with checked exceptions.
I love libraries that does a simple check and signals that it "failed" with ThingWasNotTrueException.
In surprising twist: Java has ConcurrentModificationException. And, to counter its own culture of exception misuse, the docs have a stern reminder that this exception is supposed to be thrown when there are bugs. You are not supposed to use it to, I dunno, iterate over the collection and bail out (control flow) based on getting this exception.