Obviously sum types for errors are vastly superior to exceptions. Compare the following very readable Error variant pseudocode:
fn doSomethingFallibly() -> SameVal | Error
SomeVal x = ...;
if failed:
return Error
else
return x
fn doTheThing() -> void
match doSomethingFallibly():
SomeVal x: /* use x */
Error: /* log the error */
To the utterly unreadable exception-based implementation:
fn doSomethingFallibly() -> SameVal, except Error
SomeVal x = ...;
if failed:
raise Error
else
return x
fn doTheThing() -> void
try Someval x = doSomethingFallibly()
/* use x */
except Error: /* log the error */
This is especially egregious if you want to propagate the error. For the error variant case you can use a beautiful monadic solution
fn doTheThing() -> void|Error
SomeVal x <- doSomethingFallibly()?
While the exception based variant completely obscures the control flow:
Depends on where do you want to handle a particular error.
For errors that you can handle right in the calling function, this is ideal.
For errors that should kill your whole aapp, may be do some housekeeping (like abort the whole transaction that wraps your logic to reply to this http request) and get reported to sentry, catching exceptions at some highest level is far more usable.
I prefer to use both depending on meaning of the error.
I don't like your approach. In general, I'm a fan of "use the right tool for the job". But for error handling, I prefer one approach for the whole application. If you mix approaches (exceptions here and sum types there), you tend to get bugs at the seams between the regions. If you mix them more uniformly in the code, that tends to be uniformly harder to reason about.
The issue is that in many programming languages that offer exceptions, exceptions are misused to handle common and unexceptional non-success cases. I think that is because it is hard to draw a sharp line between the two.
Java already went in the right direction with distinguishing errors from exceptions and having "checked" (that you cannot ignore) exceptions, but the implementation of those concepts sucks. Also, generics, which allow the implementation of "sum types", came later and so sum types were never established as the way to do error handling.
> exceptions are misused to handle common and unexceptional non-success cases.
This is my insurmountable objection to Exceptions.
What people will tell you is that you should only use Exceptions for "real" error conditions which should be rare, but the problem is that the people deciding whether to use Exceptions are writing some library, whereas the people who know whether this is a rare condition are the application writers, using that library only second or third hand, or maybe via some third party component. You might as well provide a language where the primitive integer types don't have a specified size so programmers don't know if numbers will fit in them...
It's also an interesting objection because it means that when you have exceptions... you still need an error handling method for non-exceptional errors.
Functions don't do things, they process and return things. Side effects are handled higher up at the root of a program. Generally any time you'd be "doing a thing" you'd also be in a position to handle the error.
source: I don't do any functional programming but have seen some videos on youtube
Jokes aside, I can think of a couple of differences:
- For the longest time, languages with typed (“checked”) exceptions lacked any parametric polymorphism worth a damn. That made the typing... less than optimally useful, especially with fallible callbacks. Sum types can borrow all that from the existing language, and you’d feel intensely awkward if you tried to go with untyped errors there (it’s of course still possible, through Dynamic or interface{} or whatever Rust’s thing is).
Though looking at the boilerplate fest that is the latest Rust (sum-types-based) error handling article posted here[1], I’m not entirely convinced the situation has improved. Perhaps open union types of some sort really are needed? That’s where the algebraic-effects languages seem to be going, anyway.
- Exceptions are more strict about what you can do with them: you can return or you can bind, monadically speaking. If an exception was raised, everything stops until it’s caught. With sum types (concrete ones, not an abstract error monad you can’t run), you are allowed to have several propagation chains at once and manipulate them as data. The lack of that additional flexibility is what makes exceptions (more cheaply?) implementable in terms of stack unwinding.
(Traditionally, stack unwinding is thought of as expensive, but I don’t really see why. Sure SJLJ-induced register spills are bad, and sure table-based unwinding is slow as hell, but a linked list of frames as in Win32 SEH[2] has to be cheaper than the same thing implemented as conditional jumps in every unwinding path, right? Right? I must be missing something here, expired Borland patent aside.)
- Concrete sum types cannot implement resumable exceptions[3], as far as I can tell. I’m almost sure an abstract error monad could, but for some reason I have never seen it done. Besides, working with that is going to require more syntactic support than what you’ve shown. (Yes, I know, such syntactic support is possible to implement and has in fact been implemented.)
1) Agree, the issues with (checked) exceptions and error monads mostly stem from expressive limitations of a specific language than the model itself.
2) The next step up is making the exception continuation first class, then I don't think there is any difference. I think you can recover the efficiency of the simple non-local goto by optimizing after CPS transform, but I'm not a compiler writer.
3) As you point out, I think the continuation monad should be able to do resumable exceptions.
You mention algebraic effects, which are probably the right way to implement Error types/Exceptions/Resumable Error handlers.
Obviously exceptions are vastly superior to sum types for errors. Compare the following very readable exception based-pseudocode:
fn doSomethingFallibly() -> SameVal, except Error
SomeVal x = ...;
if failed:
raise Error
else
return x
fn doTheThing() -> void
try Someval x = doSomethingFallibly()
/* use x */
except Error: /* log the error */
To the utterly unreadable error variant based implementation that uses obscure functional stuff like patter matching:
fn doSomethingFallibly() -> SameVal | Error
SomeVal x = ...;
if failed:
return Error
else
return x
fn doTheThing() -> void
match doSomethingFallibly():
SomeVal x: /* use x */
Error: /* log the error */
This is especially egregious if you want to propagate the error. The exception based variant can focus on the success control flow:
I confess I had a failed de-dupe bug on HN (last week I think?) and so I just assumed you fell victim to the same bug and didn't realise the posts are subtly different.
That's really a side effect :). I just wanted to point out that given the right sugar and abstraction capabilities, the two models are really equivalent.
That is both readable and safe, here fn1-fn3 all return an Either[Error,T] (the error type has to be common but the success type can vary by function).
Another take on this is the "Kroll result" after Rachel Kroll of rachelbythebay fame. Basically, it's a C++ type "Result<T>" with methods like "bool isError()" and "T get()" or something like that, but the twist is that it contains a private boolean field "checked" initialized to false, but set as a side-effect of calling isError. If you call get() and checked == false, then it blows up with an exception (I presume it does that too if you call get() and it's actually an error).
That way, if you ever write code that doesn't error-check before trying to get the "happy path" result, you'll notice immediately, even if it's the kind of thing that very rarely fails and you haven't written a unit test for it.
A chaotic good CS educator might want to try out an experiment: give the students an assignment where they have to work with an API that uses a normal result type, explain why error-checking is really important, but when you're marking the assignment switch out the result type for the Kroll one.
I guess you mean this ? https://rachelbythebay.com/w/2022/02/21/result/ The type isn't described there as "Kroll result" perhaps out of modesty, but I also found no other reference to this type by that name, and it seems more like an unfinished thought than an actual type you'd use.
Rachel seems fixated on people learning a lesson from using it wrong, rather than shifting hard left like Rust's Result so that when you get this wrong your code doesn't compile the same as if you forget to quote strings, or you write the actual arithmetic product symbol instead of an asterisk. There is no need for this code to compile in order to "learn" something.
That's the same lesson which scraped into C++ 20 errata, to make std::format("{} {}", 5); into a compile error whereas previously it was ludicrously a runtime error. Maybe at runtime the literal 5 will be two values ? No. We don't need to execute this code to realise that it's wrong. Shift hard left.
I do indeed. I think, reading between Rachel's lines, that she uses this in practice in her own projects? "Kroll result" is a term I just made up myself.
I'm guessing Rachel's decision was not so much fixation and more that only the standards body (or the designer of a new programming language) could make something into a compile-time error, but a SWE/SRE can take a small step like this on her own.
My preference is either a Result-object or the Go convention of (result, error).
Both force you to handle the error or explicitly ignore it, which is the correct way. Either you take care of it there if you have the context to figure out if it's relevant or not or you explicitly bump it one step up for the caller to handle.
That is definitely not true of the second, especially when the language is incorrectly or insufficiently pedantic like Go. Notably, because the base pattern is
foo, err := Bar()
it is very easy to write something like:
foo, err := Bar()
// code, no error check
bar, err := Baz()
// code, no error check
qux, err := Qux()
if err != nil {
...
}
And Go is perfectly happy with that, because while it will complain about unused variables it doesn't say anything about dead stores. You need external tools to warn about this issue, as well as ignoring the returns entirely.
And then of course because this pattern requires having a valid "result" value which ends up in scope, it's very easy for bugs to creep in where this result value does get used even in case of error.
It's a pretty immediate yellow flag for me if you're not doing that.
In general, I've found that not catching errors is not a practical problem when writing go. Whereas things like interface nil ambiguity and the way defer and closures interact bit me a few times.
That's all fun and games if you want `foo` to be used in some `else` block but it just straightup wrong for using `foo` further down in the method as it scopes the decl (`:=`) to the `if`
package main
import (
"fmt"
)
func Bar() (string, error) {
return "hello", nil
}
func main() {
if foo, err := Bar(); err != nil {
panic(err)
}
fmt.Printf("foo is %q", foo)
}
$ go run foo.go
./foo.go:12:8: foo declared and not used
./foo.go:15:30: undefined: foo
> In general, I've found that not catching errors is not a practical problem when writing go
Yeah, good for you, but I am obviously not a consumer of your code because I have lost track of the number of times I have to use software written by folks that try to be heroes and program using dumb editors that cheerfully don't warn about swallowing err
Sadly Go doesn't force you to handle the error (this passes go vet):
a, err := strconv.Atoi("blah")
b, err := strconv.Atoi("123")
if err != nil {
log.Fatal(err)
}
return a + b
This could be fixed to emit a vet error, but ultimately it is inferior to only returning either error or success, instead of returning both even through logically only one exists.
Not to take away from your argument, but for what it's worth, there is a lint for this [1] which is available in golangci-lint [2]. If there is any Go developer reading this who does not know about golangci-lint, I wholeheartedly recommend looking into it. It's found a fair share of significant issues for me that otherwise would have required tests to uncover.
(result, error) is very bad. It forces you to return some result even if there was an error. I've seen real production problems caused by using this garbage value after an error has happened.
Could you give examples of problems you encountered with this approach?
The only issue I can think of comes from ignoring the error. You get the same problem with Rust's Result btw. Static analysis helps in this regard, golangci-lint catches easily such mistakes.
In my experience, with production tooling, I never encountered what you described. Though, from a pure aesthetic point of view I prefer Rust's Result.
If you return an `Either[Error, T]` (to use Scala syntax) then in case of error you never have to construct a T and the caller can never read one by accident on the error path, whereas if you return a pair (T, error) then you can hit at least three cases:
1. T is an int that's sometimes legitimately zero, if you return (0, err) and the caller isn't careful they can look at the 0 without realising it's not valid.
2. T is a 'pointer' type so you return (null, error) and the caller tries to dereference the pointer anyway (one example I've seen is inserting a logging statement for debugging purposes just _before_ the error-check, along the style of `logger.debug("foo {t.name}")` where the braces interpolate stuff).
3. T lives in a strongly typed world where there's no convenient null value available (maybe your language distiniguishes between "definitely a T" and "either a T or null") so you somehow have to construct an instance of a T even though you're never going to use it and it'll most likely be invalid in some sense.
That doesn't mean the golang approach is bad - especially not in golang itself - but you do need to know how to use it correctly. Then again, using monadic error handling in golang would probably be strictly inferior - it's not that you can't define one, but the language doesn't have the syntactic sugar to handle it like e.g. Scala does, and I find that being able to use that sugar to get some readability for the approach is the whole point of monadic error handling.
Callee: creates a complex object and does several steps of initialisation. Last step fails, so it returns a partially initialized object and an error.
Caller: checks the error, which is not fatal, but expects to get a nil in this case. Ends up with a non-nil partially initialized garbage.
Yes, someone screwed up. Yes, it's a bug. Doesn't change the fact that there are better error handling approaches that eliminate this kind of bugs completely
You're right that the language doesn't protect you here.
One easy way to prevent these sorts of errors is to always return default literals with errors (in the same spirit of "value == var" from c to prevent accidental assignment).
How? Take the easy example, "clowns" is a string that's supposed to be an small integer. How do I get this wrong in Rust? We have to say what we want to happen when it won't parse which isn't ignoring it.
let clowns: u16 = clowns.parse(); // Won't compile
If we say we believe this won't happen we can express that, but when it does happen the program panics...
let clowns: u16 = clowns.parse().unwrap(); // Panics if clowns doesn't parse as a u16
We can say what we actually want to happen, but how is that the same problem?
let clowns: u16 = clowns.parse().unwrap_or(1629); // OK it's 1629 if it won't parse
Finally we can write the C-style "Nothing will go wrong" attitude using unsafe, but I don't see that as a problem, if your first instinct is to reach for unsafe Rust you're a bad programmer
let clowns: u16 = unsafe { clowns.parse.unwrap_unchecked() }; // Mark Baum says "Boom".
For starters it relies on implicit zero values in the language. These are dangerous from a modelling perspective: if you have a 0 integer, or an empty string, you can't really be sure if that's a meaningful value or merely a default. This is a bigger problem than just error handling. It makes it all too easy for invalid data to slip into your system.
Implicit zero values are a bad design decision that cause all sorts of other pain. Consider the behavior of sends and receives on nil channels [1]. This is a fundamentally nonsense operation, but Go's designers are forced to define a behavior because nil channels can exist thanks to implicit zero values.
Not a great article, very little depth, only a shallow mostly syntax-level overview. Notably doesn't at all explore the consequences of those choices in how they relate to convenience, (type) safety, composability, interaction with generics, ... beyond merely mentioning the failure of java's checked exceptions.
It's also severely missing in breadth e.g. Swift and Zig use an exception-type syntax, but result-type semantics, it does not cover conditions and restarts, or takes like Common Lisp's MRV, where the ancillary return values are treated as secondary and interacted with through special functions.
It's missing the Erlang/Elixir pattern of returning a tuple `{:ok, T}` or `{:error, E}`, where we can then use pattern matching, or `with` expressions, etc...
> To be fair, it is very similar to a `Result<T, E>` type
It's basically the same thing, in a language with pattern matching but not static typing. In Erlang/Elixir it's conventional to use tagged tuples to emulate sum types.
Agree, many practical insights in the system programming domain -- the domain is important for any discussion. For example, the unchecked exception pattern is a good choice for business applications.
I'm writing Go and Dart daily for many years (Go - 9 years, Dart - 4 years). Looking at the Go code (especially old one) I can immediately understand how and where error path is handled. In Dart code it's almost never the case – you just hope that it's handled somewhere in a right place. If I want to really find this place – it's a quest with 10-15 files open. Needless to say I end up inspecting unexpected stacktraces often (rife use of generics also lead to ubiqutuous errors like "type 'Null' is not a subtype of type 'bool'" – that's with Null safety enabled). I think Dart will be introducing even more features and pattern matching and everything-else-that-exists-in-other-languages. Complexity is piling up more and more around error handling in many languages.
Go seems to be the only one holding up the ground of caring about cognitive load on developers and code readability.
* errors from async-colored functions (ie. .then/.catch/.finally and higher order combinators on them, ie. we use a lot of p.catch(log.rescue(...)))
* errors in generators
* errors in async generators
Using functional approaches helps working with things like managing severity, error codes, nested errors etc. that may be attached to errors as well as managing higher level concepts like timeouts, retries etc.
This article reads like hello world for errors, there is more to it.
Error handlers, what is called callbacks in the article, have existed since time immaterial in operating systems. They have also been popular in PL/I and Lisp as signals and conditions. Unfortunately, Unix implementation kinda limited them (there is limited number of system signals, error handlers do not stack), so they never became really popular in C.
> For example, "printf" in C can fail, but I have not seen many programs checking its return code!
Okay, I add
if (printf(...) < 0) {
// TODO: Handle error
}
around the printf call. Now what? How do I handle the error? In most realistic scenarios I can imagine I'd either just ignore it and keep going, or print an error and abort (but what if printing an error fails too? Oh no...), or I'll never actually even get the chance to handle the error because my program will get killed by a SIGPIPE.
Seriously, if the printf fails then (barring the malformed format string) that means that the underlying device lost its ability to output data and this ability is most likely not coming back, and your program generally can't do anything with it, or even know about something to do: the functions from the FILE*-family abstract the underlying devices extremely well.
Try it, the canonical C "Hello, world" silently succeeds when given a stdout which rejects all output with an error. The canonical Rust "Hello, world" panics, emitting an error to stderr if stderr works, and doesn't succeed.
Ideally, that properly should be handled in the device driver, with buffering and retries, and not bubble all the way up. But even if it does, you can't reasonably retry the printf call since there is no guarantee that zero data has actually been written.
> multi-byte character conversion fails because the bytes sent and the current locale don’t match
That falls under "malformed format string" category; using printf for MBCS-conversion is almost always a programmer's error.
> printf runs out of memory
That falls under both "stupid library implementation" (printf doesn't need to malloc) and "still can't recover from that".
That’s changing the subject. The comment I replied to claimed “the underlying device lost its ability to output data”, not that retrying wasn’t an option. My point is that the call can fail even though the device is fine.
See above; I never claimed that. Also, this action (in general) isn’t recoverable, but programs sometimes can recover from out of memory conditions, for example by clearing their caches or by freeing a block of memory specially allocated at startup to ensure some recovery from an out of memory condition is possible.
Mmm. Now that I re-read my original comment, it does look like this. But my main point that I wanted to make was "since error from printf() is (almost always) unrecoverable, there is no much point in checking for it, you can't really do anything useful with it, so why do posts that chastise people for not checking it keep getting written?"
And the main reason for a printf() error, in my experience, is indeed the "broken" device which is usually a network socket/pipe or, rarer, a file on a network share or, even rarer, a file on a disk that ran out of free space. That's not something a program can (or even should try to) work around of, that's what its environment should do.
As for the fact that glibc has malloc()-ing implementation of printf() — glibc has many strange implementation decisions. For example, its sscanf() interanlly calls strlen() on the string to scan, so calling e.g. sscanf(1GB_LARGE_BUFFER, "%d", &out) is a bad idea, especially in a loop: https://news.ycombinator.com/item?id=26300451
Very timely, was just trying to understand how to improve error handling with typescript recently and came across neverthrow (https://github.com/supermacro/neverthrow) which looks promising…
Yeah, working with exceptions and Promise rejections is very annoying in Typescript as the error type is often any even when you yourself carefully always use Error instances to be able to get a stack trace etc. Theoreticallly at least some sub/system function might throw a string or whatever, so that has to be dealt with. Also subtyping Error classes to be able to convey error details is annoying and you'll then have to document all expected error types. And finally Typescript won't be able to help you ensure that you're dealing with all of them. This seems to solve all of that, but I haven't yet had a chance to try it out.
There's another approach that I don't see discussed often: structure operations so that code just "passes through" on errors and code the happy path.
In the case of opening a file, you would get an invalid file handle that you can still pass around to functions and nothing would "fail"; it would just be as if you passed a handle to /dev/null. This scheme requires that you can still explicitly check if the handle is valid.
Most people are familir with some form of this with the special NaN floating point value.
That's because it tends to be wonky and end up in tears as you try to find out what the source of the corruption (the original creator of the error) is. NaN is emblematic of that, as it's usually undesirable and you'd much rather have received an error instead of gotten nonsense injected in your program state.
Objective-C has this, where errors would usually end up returning a `nil` (with an NSError accessible via an optional out parameter), and sending a message to `nil` has no effect and returns `nil`, so you could code on the happy path and end up with no idea whatsoever why your thing stops working, with the only information being that you end up with a `nil`.
libc error handling also kinda but kinda not uses this pattern, with the additional fun that you can UB on the way: libc does not normally reset errno, so you can reset it (to 0), call a bunch of functions, and check errno at the end.
Except that doesn't really work because:
1. not all functions will signal errors via errno
2. third-party code may be resetting errno for its own use (errno is not unlike a caller-saved register in that sense)
3. third party code may handle errno internally, but will not reset it if so, leading to sprurious error messages.
I find NaN mostly awkward and annoying in practice. Writing graphics code, you often find that some rendering step just fails completely, and it's because you had a NaN (almost always divide by zero) somewhere along the line. Trying to pin down exactly where it occurs can be a real pain. Rather than silently passing NaN along, it would be much more useful to fail immediately and loudly.
Therefore, I'd be very wary of using this pattern elsewhere, unless you can carry along a backtrace to help track down the culprit.
On the other hand, it is useful that GL shaders will still sorta-kinda work even if you have zero divides, rather than completely blowing up. In many cases this only happens on a single pixel with very specific inputs, and you can just ignore it -- like having a single dead pixel on a monitor.
NaN does it's one job when it is annoying. It exposes errors at their origin before they have an opportunity to propagate. If you're generating NaNs your code is broken.
At their origin? How so, when you can just keep calculating and you don't get exceptions, just more and more NaNs?
Edit to add: like, if the error is "distance was zero so I couldn't invert the matrix", I want that to be the error feedback; not "the screen is black because the final result of all the camera calculations was NaN".
The monadic/Result pattern only works if your language has a lot of syntactic sugar for it (for example to mix functions that can return errors with ones that can't you need some kind of 'bind'), but when you're used to it and don't try and play too many clever tricks on the side (not _everything_ has to be a monad) then it can be very readable and easy to work with.
Nice article, and I'm not just saying that because I wrote something extremely similar myself a few years ago. ;) The one thing I'd add is that the "defer" construct deserves a mention. It's not a comprehensive error handling mechanism by itself, but can often augment or even stand in for one.