-
checked exceptions: Java
- the most infamous is Java's widely hated checked exceptions. No one else does this afaik
- good:
- you can code the "happy path" in a "direct style", and handle abnormal situations separately
- function signatures tell you exactly what exceptions can happen (except for the carve-out, see below)
- checked exceptions make it a little harder to forget to use
try/finally
try
-with-resources statement copies Python's laudedwith
-statement
- bad:
- everyone hates adding
throws WhateverException, AnotherException, YetAnotherException, OhYouForgotThisOtherException
to all their methods - it's not really clear when you should subclass unchecked
RuntimeException
instead try/finally
is notoriously hard to use right, although this is largely addressed bytry
-with-resources
- everyone hates adding
-
unchecked exceptions: C#, subset of Java, Python, Ruby
- C# hews most closely to Java, even using the same
throw/try/catch/finally
syntax, but exceptions are unchecked so there's nothrows
- Java itself has a pretty big carve-out where you can define your exception as a subclass of
RuntimeException
, all descendents of which are unchecked, and I'm pretty sure there are Java communities that make a point of systematically doing so - the practical result of unchecked exceptions in static languages like C# and Java is a pretty similar error handling story to dynamic languages like Python and Ruby
- (they went with different syntax though: Python uses
raise/try/except/finally
, and Ruby usesraise/begin/rescue/ensure
)
- (they went with different syntax though: Python uses
- good:
- you can still code the "happy path" in "direct style"
- I believe Python pioneered the lauded
with
statement which largely addresses thetry/finally
footguns, and C# copied it as theusing
statement and Java copied it astry
-with-resources (and a similar pattern is common in Ruby, not sure who got the idea from who)
- cons:
- unhandled exceptions are a pervasive form of runtime errors
- because exceptions are unchecked, it's not super easy to figure out which exceptions you should catch and handle, or to remember that you ought to try to figure that out at all
- it's very easy for the programmer to code the happy path, use
try
-with-resources correctly, but then not worry about handling any of the possible exceptions at all until they show up in production (or ideally, testing)
- unhandled exceptions are a pervasive form of runtime errors
- C# hews most closely to Java, even using the same
-
just return error values, no specific language support: C, C++ (kind of? I think?), JavaScript, Clojure
of course, C is particularly primitive, and C++ is particularly complicated
- the convention in C is a particularly primitive version of this: return error codes which are actually int constants; the result of the operation is written to an out parameter; violations of an assumption may result in undefined behavior or if you're lucky, a crash
- I don't really know C++ (does anyone?), I think C++ has an especially sprawling and varied community, but Boost, for example, recommends throwing exceptions to unwind the stack and induce destructors to handle cleanup, and not catching the exception at all. And for precondition violations, something like
assert()
is recommended instead. I believe the practical result is that obstructions to an operation are managed with something like error codes, and violations of an assumption may tripassert()
s or exceptions
- JavaScript and Clojure technically both have exceptions and
try/catch
, but I think really only for legacy reasons, in practice obstructions to an operation are managed with error values and such - the practical result, I think, is that exceptions in C++, JavaScript, and Clojure are kind of like
panic!()
in Rust: you try to avoid causing any at all, you only use them in places you think are unreachable, you don't bother trying to recover from them if they do happen, but in case they do indeed happen, you do code defensively to try to crash gracefully by cleaning things up in destructors andtry/finally
s. (The closest analogy in C is just crashing the process) - good:
- once you're used to it, it's fairly clear when to return an error value vs throw an exception: if somewhere is unreachable (or you want to fail fast, like something unimplemented), you can throw an exception, otherwise if you want to notify about an obstruction to the operation, use an error value
- it's also fairly clear when to handle an exception: don't, other than cleaning things up to attempt to crash gracefully
- it couldn't be clearer whether it's your responsibility to handle a returned error value because unlike exceptions, they don't go up the stack to find someone else to bother if you ignore them. You either drop them or you handle them
- bad:
- JavaScript in particular has a bit of a "worst-of-all-worlds" problem:
- the
try/finally
footguns badly need atry
-with-resources statements (there's a TC39 proposal for atry using()
statement) - the happy path is littered with error-handling early-return noise due to lack of language support
- the early returns have another version of the
try/finally
resource cleanup problem, unless you usetry/finally
in the right way there too - (I'm not sure how much this applies to C++, which uses destructors to handle resource cleanup automatically when the stack unwinds, or Clojure, which I suspect has alternate patterns with similar effect to Python's
with
statement, similar to Ruby)
- the
- neither error codes/values nor exceptions are statically checked at all, and can be a little too easy to ignore
- not only is there little language support, the presence of
throw/catch
in the language almost seems like it's encouraging Java or Python-style error handling. This can be exacerbated by low-effort language tutorials that try to "comprehensively" cover every language construct, instead of teaching how the language is/should be actually used in practice
- JavaScript in particular has a bit of a "worst-of-all-worlds" problem:
-
return error values, with language support: Haskell, PureScript, Scala, Rust, Elm (a little)
-
every Haskell tutorial proudly touts the monadic
Maybe
andEither
types, which eliminate unhandled runtime exceptions, especiallyNullPointerException
s, and monadicdo
-notation lets you use them in apparent "direct style".- However, I think this is actually only ergonomic for extremely localized error handling within pure functions. Mixing monads, like mixing code that has side-effects with
Maybe
orEither
, is notoriously painful - Haskell ultimately compromises on purity anyway, allowing even pure functions to be "partial" and throw errors with
error
. They can only be caught in theIO
monad, although like JavaScript exceptions or Rustpanic!()
, you don't normally want to catch them anyway - there are also several additional ways to manage errors, for added confusion (some have been unified or deprecated), all for Haskell-specific problems (lazy evaluation or more monad problems)
PureScript and Scala appear to be in basically the same boat as Haskell
- PureScript very similar to Haskell down to the
do
-notation, although it is strictly evaluated rather than lazy. (It used to be more different with its row-typed algebraic effect system, but apparently that had enough problems that they switched to an equivalent of Haskell'sIO
monad) - Scala appears to treat its unchecked JVM exceptions much like Clojure (and much like JavaScript exceptions and Rust's
panic!()
). Recoverable errors/obstructions to the operation are instead meant to be handled with itsOption
andTry
types, analogous to Haskell'sMaybe
andEither
(although Scala also has anEither
type for good measure), which you can pattern-match on. You can even usefor
comprehensions to monadically code in a sort-of "direct style", although I think the syntax is weird when used that way and I don't know if anyone but the people obsessed with contorting Scala into functional programming idioms do that
- However, I think this is actually only ergonomic for extremely localized error handling within pure functions. Mixing monads, like mixing code that has side-effects with
-
Rust also takes significant inspiration from Haskell, with pattern-matching and
Option
andResult
types analogous toMaybe
andEither
for obstructions to an operation, and runtimepanic!()
for violations of an assumption.- Rust does one better than Haskell though: instead of
do
-notation, the?
operator is specialized toOption
andResult
and makes it easy to short-circuit uponNone
orErr
, while still forcing the programmer to have to choose to do so
- Rust does one better than Haskell though: instead of
-
Elm is like Haskell but without
do
-notation, or any replacement, really. Pattern-match or bust -
good:
- runtime errors are rare in practice without serious misuse of
error
/panic!()
- unhandled errors are technically impossible due to typechecking, although that can't prevent misuse of
error
/panic!()
of course - Rust's
?
operator is brilliant in my opinion. A bit of friction to ensure it's impossible to forget to handle an error, but minimal friction, it's just one character - I think with the "obstruction to an operation" vs "violation of an assumption" framework, it's pretty easy to see when to use
Result
and when topanic!()
- runtime errors are rare in practice without serious misuse of
-
bad:
- I'm not sure how much thought has been put into the
try/finally
usability problem. Haskell and PureScript as a functional languages aren't really supposed toerror
ever (I think Clojure has this attitude as well); Elm as a frontend language isn't usually used for a lot of resource management. No idea about Scala.- Rust destructors, like C++, are tied to function stack frames, so they should work fine with
panic!()
stack-unwinding or early-returns on error. Some panics abort the process instead of unwinding the stack, but I guess in those cases it's the OS's problem
- Rust destructors, like C++, are tied to function stack frames, so they should work fine with
- forced handling of errors can be annoying, especially if it's obvious to the programmer they're actually impossible; in Haskell and Rust that leads to
fromJust/.unwrap()
littering the code, in Elm doesn't even give you that option, you're supposed to just pattern-match anyway
- I'm not sure how much thought has been put into the
-
-
oddities: Go, Erlang
-
Go's error handling gets a lot of hate, which I think is mainly because errors are checked (the compiler complains about unhandled errors), but there's no sugar for either early-return-on-error or panic-on-error, so it feels like you're writing a lot of boilerplate—that blogpost is about writing your own such sugar, which seems like kinda missing the point. I think Go pioneered the
defer
statement, though, which I do think was brilliant, and was copied by Swift and Zig.I'm not sure which I like more, the `defer` statement or Python's `with` statement (aka `try`-with-resources)
- API design
with
makes it a little easier to design an API that's difficult to misuse, like instead of returning a file object you could return a context object that you have to enter in order to use the file- the problem isn't people forgetting to call
file.close()
at all, the problem is people thinking they called it correctly when actually they nestedtry/finally
subtly incorrectly, whichdefer
solves
- readability
with
is a little more readable, there's slightly less code because you don't have to explicitly call.close()
, it's implicitdefer
doesn't introduce additional indentation, whereaswith
can be unpredictable: you can enter two contexts with onewith
statement, but what if you wanted to do something in between entering the two contexts?
- flexibility
with
is highly flexible, you can even simulatedefer
withExitStack()
, such as to reduce indentation- basic usage of
defer
is more flexible though, you don't have to bother with something that implements a "context manager" interface, just run a line of code out-of-order
On balance, I think I prefer
defer
. As long as Mechanical doesn't have control flow, though, it doesn't really matter. - API design
-
systems written in Erlang are famous for their uptime, and a big part of that is error recovery, but not really in the sense discussed here. Erlang is unusual in many ways—no mutable data or variables (like Haskell), but dynamically typed (like Lisp)—but its control flow is actually fairly mainstream, with exceptions that unwind the stack and
try/catch/after
for catching them. As far as I can tell, Erlang has nothing likedefer
ortry
-with-resources.- Erlang's famous reliability isn't due to the localized, fine-grained exception handling, but rather due to the coarse-grained error handling and recovery at "process" boundaries—in Erlang, it's customary to create lots of green threads, which Erlang calls "processes" because they don't share a namespace. Processes are organized into "supervisor trees", so that when processes encounter an error, instead of worrying about recovery from it, it can exit and let its supervisor restart it.
- This leads to the philosophy of Let It Crash: "only program the happy case, what the specification says the task is supposed to do". Doing lots of fine-grained error handling in an attempt to code "defensively" and be "robust" is actually counter-productive. Instead, when surprising stuff happens, just let it crash, and put your effort into designing an overall system architecture that is robust to surprises in the first place.
- Or to put this in terms of "obstructions" and "assumption violations", this of course doesn't mean you should crash in response to any and every predictable obstruction to an operation. Predictable obstructions should be considered in the specification and handled sensibly. But it's counter-productive to try to code defensively by making fewer assumption than the spec, instead you shouldn't be afraid to make assumptions and crash when they're violated.
-
-
Swift, Zig, Go sort of (it has defer)---return-oriented with more thought put into it
The biggest inspiration for Mechanical's error handling is Zig, Swift, and Rust, with lessons from Haskell, Go, Python, and of course, JavaScript and Java.